From 403913e899b65cba56beb08a3ee6e584299239a7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 5 Jul 2026 19:00:23 +0100 Subject: [PATCH] Route RTS projectiles through daemon Ymir boundary --- .../aetheria-rts-generated-bindings.ts | 49 ++++- .../Electron/aetheria-rts-local-documents.ts | 42 +++- .../scripts/generate-rts-bindings.mjs | 4 + Aetheria.State.Verify/Program.cs | 122 +++++++++++ .../Tests/DaemonRuntimeDocumentTests.cs | 82 ++++++- GameCult.Aetheria.State.Unity.csproj | 1 + .../Runtime/AetheriaRuntimeRtsDocuments.cs | 53 +++++ .../Runtime/AetheriaRuntimeRtsSimulation.cs | 118 +++++++++- .../AetheriaRuntimeRtsViewportDocuments.cs | 4 + .../AetheriaRuntimeSnapshotDocuments.cs | 62 ++++++ .../AetheriaRuntimeYmirProjectilePhysics.cs | 207 ++++++++++++++++++ docs/aetheria-current-codebase-model.md | 3 + 12 files changed, 736 insertions(+), 11 deletions(-) create mode 100644 Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeYmirProjectilePhysics.cs diff --git a/Aetheria.Rts.Web/Electron/aetheria-rts-generated-bindings.ts b/Aetheria.Rts.Web/Electron/aetheria-rts-generated-bindings.ts index 507b75ac8..3876d003f 100644 --- a/Aetheria.Rts.Web/Electron/aetheria-rts-generated-bindings.ts +++ b/Aetheria.Rts.Web/Electron/aetheria-rts-generated-bindings.ts @@ -842,7 +842,8 @@ export type AetheriaRuntimeZoneRenderDocumentSlot = | "droppedPickups" | "entitySnapshots" | "orbits" - | "bodies"; + | "bodies" + | "projectiles"; export const aetheriaRuntimeZoneRenderDocumentSlots: Record = { schema: 0, @@ -863,6 +864,7 @@ export const aetheriaRuntimeZoneRenderDocumentSlots: Record = { zoneIndex: 0, @@ -1011,6 +1014,7 @@ export const aetheriaRuntimeZoneSnapshotCommitSlots: Record = { + projectileId: 0, + sourceEntityIndex: 1, + targetEntityIndex: 2, + factionKey: 3, + positionX: 4, + positionY: 5, + positionZ: 6, + directionX: 7, + directionY: 8, + velocityX: 9, + velocityY: 10, + damage: 11, + radius: 12, + ageSeconds: 13, + lifetimeSeconds: 14, + guided: 15, + active: 16, + weaponKind: 17, +}; + export type AetheriaRuntimeBodySnapshotCommitSlot = | "kind" | "name" diff --git a/Aetheria.Rts.Web/Electron/aetheria-rts-local-documents.ts b/Aetheria.Rts.Web/Electron/aetheria-rts-local-documents.ts index 54260cfef..d675d78e7 100644 --- a/Aetheria.Rts.Web/Electron/aetheria-rts-local-documents.ts +++ b/Aetheria.Rts.Web/Electron/aetheria-rts-local-documents.ts @@ -12,6 +12,7 @@ import { aetheriaRuntimeEntityStatGridCommitSlots as statGridSlots, aetheriaRuntimeLoadoutItemCommitSlots as itemSlots, aetheriaRuntimeLoadoutItemSlotCommitSlots as itemSlotSlots, + aetheriaRuntimeProjectileCommitSlots as projectileSlots, aetheriaRuntimeRunCheckpointCommitSlots as runSlots, aetheriaRuntimeSunVisualCommitSlots as sunVisualSlots, aetheriaRuntimeStarbridgeBaseStatusSlots as starbridgeBaseSlots, @@ -82,12 +83,17 @@ export function buildObjectsViewportDocumentFromFrame( .map(entity => num(entity[entitySlots.entityIndex], -1)) .filter(index => index >= 0); const controlled = entities.filter(entity => controlledEntityIndices.includes(num(entity[entitySlots.entityIndex], -1))); + const projectiles = list(zone[zoneSlots.projectiles]); const objects = entities .filter(entity => entityIntersectsViewport(entity, viewport)) .filter(entity => isPlayerControlled(entity) || controlled.length === 0 || controlled.some(observer => canSee(observer, entity))) - .map(entity => toViewObject(entity, runId, num(zone[zoneSlots.zoneIndex]))); + .map(entity => toViewObject(entity, runId, num(zone[zoneSlots.zoneIndex]))) + .concat(projectiles + .filter(projectile => projectile[projectileSlots.active] !== false) + .filter(projectile => projectileIntersectsViewport(projectile, viewport)) + .map(toProjectileViewObject)); return { schema: AetheriaRtsSchemas.objectsViewport, @@ -496,6 +502,34 @@ function toViewObject(entity: unknown[], runId: string, zoneIndex: number): View }; } +function toProjectileViewObject(projectile: unknown[]): ViewObject { + return { + entityIndex: -1, + entityKey: str(projectile[projectileSlots.projectileId]), + displayName: str(projectile[projectileSlots.weaponKind]) || "projectile", + kind: "projectile", + factionKey: str(projectile[projectileSlots.factionKey]), + x: num(projectile[projectileSlots.positionX]), + y: num(projectile[projectileSlots.positionZ]), + z: num(projectile[projectileSlots.positionY]), + directionX: num(projectile[projectileSlots.directionX]), + directionY: num(projectile[projectileSlots.directionY]), + velocityX: num(projectile[projectileSlots.velocityX]), + velocityY: num(projectile[projectileSlots.velocityY]), + controlled: false, + targetEntityIndex: num(projectile[projectileSlots.targetEntityIndex], -1), + isActive: projectile[projectileSlots.active] !== false, + visibility: num(projectile[projectileSlots.radius]), + iconAsset: spriteAsset("aetheria.asset.sprite.rts.projectile", "Sprites/Icons/Lightning Bolt"), + status: { + hull: num(projectile[projectileSlots.damage]), + shield: 0, + heat: num(projectile[projectileSlots.ageSeconds]), + }, + inventory: [], + }; +} + function toStarbridgeBaseStatus(status: unknown[]): StarbridgeSessionDocument["baseStatus"] { return { entityKey: str(status[starbridgeBaseSlots.entityKey]), @@ -601,6 +635,12 @@ function entityIntersectsViewport(entity: unknown[], viewport: ViewportRequest): return x >= viewport.minX && x <= viewport.maxX && y >= viewport.minY && y <= viewport.maxY; } +function projectileIntersectsViewport(projectile: unknown[], viewport: ViewportRequest): boolean { + const x = num(projectile[projectileSlots.positionX]); + const y = num(projectile[projectileSlots.positionZ]); + return x >= viewport.minX && x <= viewport.maxX && y >= viewport.minY && y <= viewport.maxY; +} + function gravityInfluenceIntersectsViewport(body: unknown[], viewport: ViewportRequest): boolean { const x = num(body[bodySlots.gravityInfluenceCenterX]); const y = num(body[bodySlots.gravityInfluenceCenterZ]); diff --git a/Aetheria.Rts.Web/scripts/generate-rts-bindings.mjs b/Aetheria.Rts.Web/scripts/generate-rts-bindings.mjs index f5370becb..0869b2d2c 100644 --- a/Aetheria.Rts.Web/scripts/generate-rts-bindings.mjs +++ b/Aetheria.Rts.Web/scripts/generate-rts-bindings.mjs @@ -201,6 +201,10 @@ const documents = [ exportName: "aetheriaRuntimeEntitySnapshotCommit", className: "AetheriaRuntimeEntitySnapshotCommit", }, + { + exportName: "aetheriaRuntimeProjectileCommit", + className: "AetheriaRuntimeProjectileCommit", + }, { exportName: "aetheriaRuntimeBodySnapshotCommit", className: "AetheriaRuntimeBodySnapshotCommit", diff --git a/Aetheria.State.Verify/Program.cs b/Aetheria.State.Verify/Program.cs index 4f7018636..a74c52aee 100644 --- a/Aetheria.State.Verify/Program.cs +++ b/Aetheria.State.Verify/Program.cs @@ -16041,6 +16041,128 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root) string.Join(", ", missingDaemonTickSymbols)); } + var snapshotDocumentsPath = Path.Combine( + root, + "Packages", + "org.gamecult.aetheria.state", + "Runtime", + "AetheriaRuntimeSnapshotDocuments.cs"); + var rtsSimulationPath = Path.Combine( + root, + "Packages", + "org.gamecult.aetheria.state", + "Runtime", + "AetheriaRuntimeRtsSimulation.cs"); + var ymirProjectilePhysicsPath = Path.Combine( + root, + "Packages", + "org.gamecult.aetheria.state", + "Runtime", + "AetheriaRuntimeYmirProjectilePhysics.cs"); + var rtsDocumentsPath = Path.Combine( + root, + "Packages", + "org.gamecult.aetheria.state", + "Runtime", + "AetheriaRuntimeRtsDocuments.cs"); + var rtsViewportDocumentsPath = Path.Combine( + root, + "Packages", + "org.gamecult.aetheria.state", + "Runtime", + "AetheriaRuntimeRtsViewportDocuments.cs"); + var rtsLocalDocumentsPath = Path.Combine(root, "Aetheria.Rts.Web", "Electron", "aetheria-rts-local-documents.ts"); + var snapshotDocuments = File.Exists(snapshotDocumentsPath) + ? File.ReadAllText(snapshotDocumentsPath) + : throw new InvalidOperationException("Cannot verify daemon projectile authority; runtime snapshot documents are missing."); + var rtsSimulation = File.Exists(rtsSimulationPath) + ? File.ReadAllText(rtsSimulationPath) + : throw new InvalidOperationException("Cannot verify daemon projectile authority; RTS simulation is missing."); + var ymirProjectilePhysics = File.Exists(ymirProjectilePhysicsPath) + ? File.ReadAllText(ymirProjectilePhysicsPath) + : throw new InvalidOperationException("Cannot verify daemon projectile authority; Ymir projectile physics boundary is missing."); + var rtsDocuments = File.Exists(rtsDocumentsPath) + ? File.ReadAllText(rtsDocumentsPath) + : throw new InvalidOperationException("Cannot verify daemon projectile authority; RTS viewport documents are missing."); + var rtsViewportDocuments = File.Exists(rtsViewportDocumentsPath) + ? File.ReadAllText(rtsViewportDocumentsPath) + : throw new InvalidOperationException("Cannot verify daemon projectile authority; zone render documents are missing."); + var rtsLocalDocuments = File.Exists(rtsLocalDocumentsPath) + ? File.ReadAllText(rtsLocalDocumentsPath) + : throw new InvalidOperationException("Cannot verify daemon projectile authority; Electron local projection is missing."); + + var requiredProjectileAuthoritySymbols = new Dictionary + { + [snapshotDocumentsPath] = new[] + { + "public IReadOnlyList Projectiles", + "public sealed class AetheriaRuntimeProjectileCommit" + }, + [rtsSimulationPath] = new[] + { + "StepCombat(zone, entities, deltaSeconds)", + "EnsureDaemonWeaponState(", + "SpawnProjectile(zone, attacker, target)", + "AetheriaRuntimeYmirProjectilePhysics.Step(zone, entities, deltaSeconds)", + "Damage(target, hit.Projectile.Damage)" + }, + [ymirProjectilePhysicsPath] = new[] + { + "public static class AetheriaRuntimeYmirProjectilePhysics", + "public static AetheriaRuntimeYmirProjectileStep Step(", + "ProjectileBodyPrefix", + "DaemonEntityBodyPrefix", + "TryResolveProjectileContact(", + "public sealed class AetheriaRuntimeYmirProjectileHit" + }, + [rtsDocumentsPath] = new[] + { + "var projectiles = zone.Projectiles", + ".Concat(projectiles", + "private static AetheriaRuntimeRtsViewportObject ToViewportObject(", + "AetheriaRuntimeProjectileCommit projectile)" + }, + [rtsViewportDocumentsPath] = new[] + { + "public IReadOnlyList Projectiles" + }, + [rtsLocalDocumentsPath] = new[] + { + "const projectiles = list(zone[zoneSlots.projectiles])", + ".map(toProjectileViewObject)", + "function toProjectileViewObject(projectile: unknown[]): ViewObject" + } + }; + var missingProjectileAuthoritySymbols = requiredProjectileAuthoritySymbols + .SelectMany(pair => + { + var text = pair.Key == snapshotDocumentsPath ? snapshotDocuments : + pair.Key == rtsSimulationPath ? rtsSimulation : + pair.Key == ymirProjectilePhysicsPath ? ymirProjectilePhysics : + pair.Key == rtsDocumentsPath ? rtsDocuments : + pair.Key == rtsViewportDocumentsPath ? rtsViewportDocuments : + rtsLocalDocuments; + return pair.Value + .Where(symbol => !text.Contains(symbol, StringComparison.Ordinal)) + .Select(symbol => $"{Path.GetRelativePath(root, pair.Key)}: missing {symbol}"); + }) + .ToArray(); + if (missingProjectileAuthoritySymbols.Length > 0) + { + throw new InvalidOperationException( + "Daemon RTS combat must publish moving typed projectiles instead of invisible local/direct-damage shortcuts: " + + string.Join("; ", missingProjectileAuthoritySymbols)); + } + + if (rtsSimulation.Contains("Damage(target, damage)", StringComparison.Ordinal) || + rtsSimulation.Contains("Damage(target, ResolveProjectileDamage(attacker)", StringComparison.Ordinal) || + rtsSimulation.Contains("private static void StepProjectiles(", StringComparison.Ordinal) || + rtsSimulation.Contains("private static bool ProjectileHits(", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Daemon RTS combat regressed to direct target damage or local projectile physics instead of Ymir-shaped projectile contact damage."); + } + if (daemonTickRunner.Contains("AetheriaRuntimeDaemonFrameStore.PublishFrame", StringComparison.Ordinal)) { throw new InvalidOperationException( diff --git a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs index e1a4f01b6..14a101211 100644 --- a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs +++ b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs @@ -13,7 +13,6 @@ using EveSurfaceCommandRequest = GameCult.Eve.Surface.EveSurfaceCommandRequest; using EveSurfaceComponent = GameCult.Eve.Surface.EveSurfaceComponent; using EveSurfaceDocument = GameCult.Eve.Surface.EveSurfaceDocument; using EveSurfaceTree = GameCult.Eve.Surface.EveSurfaceTree; -using EveSurfaceDocument = GameCult.Eve.Surface.EveSurfaceDocument; public class DaemonRuntimeDocumentTests { @@ -1224,7 +1223,7 @@ public class DaemonRuntimeDocumentTests Assert.AreEqual(64, settings.ZoneBoundaryDepth); Assert.AreEqual(4, settings.AsteroidMeshCount); Assert.AreEqual(3, settings.DefaultMinimapZoom); - CollectionAssert.AreEqual(new[] { 250.0, 500.0, 1000.0, 2000.0, 4000.0 }, settings.ResolveMinimapZoomLevels().ToArray()); + CollectionAssert.AreEqual(new[] { 250.0, 500.0, 1000.0, 2000.0, 4000.0 }, settings.MinimapZoomLevels.ToArray()); Assert.AreEqual(2000, settings.ResolveDefaultMinimapDistance()); Assert.AreEqual(0.15, settings.BodyIconSizeCurve.Exponent); Assert.AreEqual(10, settings.BodyIconSizeCurve.Multiplier); @@ -1883,6 +1882,85 @@ public class DaemonRuntimeDocumentTests Assert.AreEqual(0.6 * 25, zoneRender.WormholeExits[0].PositionZ, 0.0001); } + [Test] + public void RtsSimulationPublishesDaemonProjectilesThroughRenderDocuments() + { + var run = new AetheriaRuntimeRunCheckpointCommit + { + RunId = "projectile-run", + CurrentZoneIndex = 0, + CurrentEntityKey = "zone.0.entity.0", + Zones = new[] + { + new AetheriaRuntimeZoneSnapshotCommit + { + ZoneIndex = 0, + SimulationTimeSeconds = 12, + Entities = new[] + { + new AetheriaRuntimeEntitySnapshotCommit + { + EntityIndex = 0, + Name = "Vanguard", + Kind = "ship", + FactionKey = "player", + IsActive = true, + TargetEntityIndex = 1, + PositionX = 0, + PositionZ = 0 + }, + new AetheriaRuntimeEntitySnapshotCommit + { + EntityIndex = 1, + Name = "Raider", + Kind = "ship", + FactionKey = "raider", + IsActive = true, + TargetEntityIndex = 0, + PositionX = 80, + PositionZ = 0 + } + } + } + } + }; + + AetheriaRuntimeRtsSimulation.Step( + run, + new AetheriaRuntimeDaemonIntentState(), + 0.1); + + var zone = FindZone(run, 0); + Assert.IsNotEmpty(zone.Projectiles); + Assert.IsTrue(zone.Projectiles.All(projectile => projectile.Active)); + Assert.IsTrue(zone.Projectiles.Any(projectile => projectile.SourceEntityIndex == 0 && projectile.TargetEntityIndex == 1)); + Assert.IsTrue(zone.Entities.All(entity => entity.WeaponStates.Any(weapon => + weapon.OwnerKind == "daemon-rts" && + weapon.BehaviorKind == "ProjectileWeapon"))); + + var frame = AetheriaRuntimeDaemonFrameDocument.Create( + run, + "daemon", + "session", + 77, + 12.1, + 0.1); + var viewport = new AetheriaRuntimeRtsViewportBounds + { + MinX = -100, + MinY = -100, + MaxX = 140, + MaxY = 100 + }; + var zoneRender = AetheriaRuntimeRtsDocuments.ZoneRender(frame); + var objectsViewport = AetheriaRuntimeRtsDocuments.ObjectsViewport(frame, viewport); + + Assert.AreEqual(zone.Projectiles.Count, zoneRender.Projectiles.Count); + Assert.IsTrue(objectsViewport.Objects.Any(obj => + obj.Kind == "projectile" && + obj.IconAsset.AssetKey == "aetheria.asset.sprite.rts.projectile")); + } + [Test] public void SoaViewIndexRejectsInvalidRenderGroups() { diff --git a/GameCult.Aetheria.State.Unity.csproj b/GameCult.Aetheria.State.Unity.csproj index f044c398e..2d6996550 100644 --- a/GameCult.Aetheria.State.Unity.csproj +++ b/GameCult.Aetheria.State.Unity.csproj @@ -101,6 +101,7 @@ + diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsDocuments.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsDocuments.cs index 7ff555aac..1e96bceeb 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsDocuments.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsDocuments.cs @@ -44,6 +44,7 @@ namespace GameCult.Aetheria.State.Verse var run = context.Run; var zone = context.Zone; var entities = zone.Entities ?? Array.Empty(); + var projectiles = zone.Projectiles ?? Array.Empty(); var controlledEntityIndices = entities .Where(IsPlayerControlled) .Select(entity => entity.EntityIndex) @@ -69,6 +70,10 @@ namespace GameCult.Aetheria.State.Verse controlled.Length == 0 || controlled.Any(observer => CanSee(observer, entity))) .Select(entity => ToViewportObject(entity, context.RunId, zone.ZoneIndex)) + .Concat(projectiles + .Where(projectile => projectile != null && projectile.Active) + .Where(projectile => IntersectsViewport(projectile, normalizedViewport)) + .Select(ToViewportObject)) .ToArray() }; } @@ -725,6 +730,9 @@ namespace GameCult.Aetheria.State.Verse .ToArray(), Bodies = (zone.Bodies ?? Array.Empty()) .Where(body => body != null) + .ToArray(), + Projectiles = (zone.Projectiles ?? Array.Empty()) + .Where(projectile => projectile != null && projectile.Active) .ToArray() }; } @@ -795,6 +803,16 @@ namespace GameCult.Aetheria.State.Verse entity.PositionZ <= viewport.MaxY; } + public static bool IntersectsViewport( + AetheriaRuntimeProjectileCommit projectile, + AetheriaRuntimeRtsViewportBounds viewport) + { + return projectile.PositionX >= viewport.MinX && + projectile.PositionX <= viewport.MaxX && + projectile.PositionZ >= viewport.MinY && + projectile.PositionZ <= viewport.MaxY; + } + public static bool GravityInfluenceIntersectsViewport( AetheriaRuntimeBodySnapshotCommit body, AetheriaRuntimeRtsViewportBounds viewport) @@ -848,6 +866,41 @@ namespace GameCult.Aetheria.State.Verse return obj; } + private static AetheriaRuntimeRtsViewportObject ToViewportObject( + AetheriaRuntimeProjectileCommit projectile) + { + return new AetheriaRuntimeRtsViewportObject + { + EntityIndex = -1, + EntityKey = projectile.ProjectileId ?? "", + DisplayName = projectile.WeaponKind ?? "projectile", + Kind = "projectile", + FactionKey = projectile.FactionKey ?? "", + X = projectile.PositionX, + Y = projectile.PositionZ, + Z = projectile.PositionY, + DirectionX = projectile.DirectionX, + DirectionY = projectile.DirectionY, + VelocityX = projectile.VelocityX, + VelocityY = projectile.VelocityY, + Controlled = false, + TargetEntityIndex = projectile.TargetEntityIndex, + IsActive = projectile.Active, + Visibility = projectile.Radius, + Status = new AetheriaRuntimeRtsEntityStatus + { + Hull = projectile.Damage, + Shield = 0, + Heat = projectile.AgeSeconds + }, + Inventory = Array.Empty(), + IconAsset = AetheriaRuntimeAssetRef.FromKey( + "aetheria.asset.sprite.rts.projectile", + AetheriaRuntimeAssetKinds.Sprite, + "resources://Sprites/Icons/Lightning Bolt") + }; + } + private static AetheriaRuntimeRtsBodyView ToBodyView( AetheriaRuntimeBodySnapshotCommit body, AetheriaRuntimeDaemonRenderSettings renderSettings) diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsSimulation.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsSimulation.cs index cb989a8ab..6360874e1 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsSimulation.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsSimulation.cs @@ -14,8 +14,12 @@ namespace GameCult.Aetheria.State.Verse private const double PawnSpeed = 84.0; private const double RaiderSpeed = 68.0; private const double AttackRange = 145.0; - private const double PawnDamagePerSecond = 18.0; - private const double RaiderDamagePerSecond = 12.0; + private const double PawnProjectileDamage = 18.0; + private const double RaiderProjectileDamage = 12.0; + private const double WeaponCooldownSeconds = 0.55; + private const double ProjectileSpeed = 330.0; + private const double ProjectileRadius = 18.0; + private const double ProjectileLifetimeSeconds = 2.2; public static void Step( AetheriaRuntimeRunCheckpointCommit run, @@ -41,7 +45,7 @@ namespace GameCult.Aetheria.State.Verse StepRaiderAi(entities); StepTargetPursuit(entities); StepMovement(entities, deltaSeconds); - StepCombat(entities, deltaSeconds); + StepCombat(zone, entities, deltaSeconds); RefreshContacts(entities); } } @@ -142,12 +146,18 @@ namespace GameCult.Aetheria.State.Verse } private static void StepCombat( + AetheriaRuntimeZoneSnapshotCommit zone, IReadOnlyList entities, double deltaSeconds) { var byIndex = entities.ToDictionary(entity => entity.EntityIndex); foreach (var attacker in entities) { + var weaponState = EnsureDaemonWeaponState(attacker); + weaponState.Firing = false; + weaponState.CooldownProgress = Math.Max(0, weaponState.CooldownProgress - deltaSeconds); + weaponState.CoolingDown = weaponState.CooldownProgress > 0; + if (!IsAlive(attacker) || attacker.TargetEntityIndex < 0 || !byIndex.TryGetValue(attacker.TargetEntityIndex, out var target) || @@ -160,9 +170,26 @@ namespace GameCult.Aetheria.State.Verse if (DistanceSq(attacker, target) > AttackRange * AttackRange) continue; - var damage = (IsPlayerOwned(attacker) ? PawnDamagePerSecond : RaiderDamagePerSecond) * deltaSeconds; - Damage(target, damage); - SetStat(attacker, Heat, Math.Min(100, GetStat(attacker, Heat) + damage * 0.18)); + Face(attacker, target.PositionX - attacker.PositionX, target.PositionZ - attacker.PositionZ); + weaponState.LockTargetEntityIndex = target.EntityIndex; + weaponState.LockProgress = 1.0; + + if (weaponState.CooldownProgress > 0) + continue; + + SpawnProjectile(zone, attacker, target); + weaponState.Firing = true; + weaponState.CoolingDown = true; + weaponState.CooldownProgress = WeaponCooldownSeconds; + SetStat(attacker, Heat, Math.Min(100, GetStat(attacker, Heat) + ResolveProjectileDamage(attacker) * 0.18)); + } + + var projectileStep = AetheriaRuntimeYmirProjectilePhysics.Step(zone, entities, deltaSeconds); + zone.Projectiles = projectileStep.Projectiles; + foreach (var hit in projectileStep.Hits) + { + if (byIndex.TryGetValue(hit.TargetEntityIndex, out var target)) + Damage(target, hit.Projectile.Damage); } foreach (var entity in entities) @@ -178,6 +205,85 @@ namespace GameCult.Aetheria.State.Verse } } + private static AetheriaRuntimeWeaponStateCommit EnsureDaemonWeaponState( + AetheriaRuntimeEntitySnapshotCommit entity) + { + var states = (entity.WeaponStates ?? Array.Empty()).ToList(); + var state = states.FirstOrDefault(candidate => + candidate != null && + string.Equals(candidate.OwnerKind, "daemon-rts", StringComparison.Ordinal) && + candidate.OwnerIndex == entity.EntityIndex); + if (state != null) + return state; + + state = new AetheriaRuntimeWeaponStateCommit + { + OwnerKind = "daemon-rts", + OwnerIndex = entity.EntityIndex, + BehaviorIndex = 0, + BehaviorKind = "ProjectileWeapon", + Ammo = -1, + BurstRemaining = 0, + BurstInterval = WeaponCooldownSeconds, + LockTargetEntityIndex = -1 + }; + states.Add(state); + entity.WeaponStates = states.ToArray(); + return state; + } + + private static void SpawnProjectile( + AetheriaRuntimeZoneSnapshotCommit zone, + AetheriaRuntimeEntitySnapshotCommit attacker, + AetheriaRuntimeEntitySnapshotCommit target) + { + var direction = Normalize(target.PositionX - attacker.PositionX, target.PositionZ - attacker.PositionZ); + if (Math.Abs(direction.X) + Math.Abs(direction.Y) <= 0.0001) + direction = Normalize(attacker.DirectionX, attacker.DirectionY); + if (Math.Abs(direction.X) + Math.Abs(direction.Y) <= 0.0001) + direction = (0, 1); + + var projectiles = (zone.Projectiles ?? Array.Empty()) + .Where(projectile => projectile != null && projectile.Active) + .ToList(); + projectiles.Add(new AetheriaRuntimeProjectileCommit + { + ProjectileId = CreateProjectileId(zone, attacker, target, projectiles.Count), + SourceEntityIndex = attacker.EntityIndex, + TargetEntityIndex = target.EntityIndex, + FactionKey = attacker.FactionKey ?? "", + PositionX = attacker.PositionX + direction.X * 18.0, + PositionY = attacker.PositionY, + PositionZ = attacker.PositionZ + direction.Y * 18.0, + DirectionX = direction.X, + DirectionY = direction.Y, + VelocityX = direction.X * ProjectileSpeed, + VelocityY = direction.Y * ProjectileSpeed, + Damage = ResolveProjectileDamage(attacker), + Radius = ProjectileRadius, + LifetimeSeconds = ProjectileLifetimeSeconds, + Guided = true, + Active = true, + WeaponKind = IsPlayerOwned(attacker) ? "vanguard-bolt" : "raider-bolt" + }); + zone.Projectiles = projectiles.ToArray(); + } + + private static string CreateProjectileId( + AetheriaRuntimeZoneSnapshotCommit zone, + AetheriaRuntimeEntitySnapshotCommit attacker, + AetheriaRuntimeEntitySnapshotCommit target, + int ordinal) + { + var tick = Math.Max(0, (long)Math.Round(zone.SimulationTimeSeconds * 1000.0)); + return $"projectile:{zone.ZoneIndex}:{tick}:{attacker.EntityIndex}:{target.EntityIndex}:{ordinal}"; + } + + private static double ResolveProjectileDamage(AetheriaRuntimeEntitySnapshotCommit attacker) + { + return IsPlayerOwned(attacker) ? PawnProjectileDamage : RaiderProjectileDamage; + } + private static void RefreshContacts(IReadOnlyList entities) { foreach (var observer in entities) diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsViewportDocuments.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsViewportDocuments.cs index 1bbbebb08..145559f99 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsViewportDocuments.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsViewportDocuments.cs @@ -880,6 +880,10 @@ namespace GameCult.Aetheria.State.Verse public IReadOnlyList Bodies { get; set; } = Array.Empty(); + [Key(20)] + public IReadOnlyList Projectiles { get; set; } = + Array.Empty(); + public static string EntityRecordKey(string runId, int zoneIndex, int entityIndex) { return string.IsNullOrWhiteSpace(runId) diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs index f9736aa5d..5c412401b 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs @@ -485,6 +485,68 @@ namespace GameCult.Aetheria.State.Verse [Key(16)] public double SimulationTimeSeconds { get; set; } + + [Key(17)] + public IReadOnlyList Projectiles { get; set; } = + Array.Empty(); + } + + [MessagePackObject] + public sealed class AetheriaRuntimeProjectileCommit + { + [Key(0)] + public string ProjectileId { get; set; } = ""; + + [Key(1)] + public int SourceEntityIndex { get; set; } = -1; + + [Key(2)] + public int TargetEntityIndex { get; set; } = -1; + + [Key(3)] + public string FactionKey { get; set; } = ""; + + [Key(4)] + public double PositionX { get; set; } + + [Key(5)] + public double PositionY { get; set; } + + [Key(6)] + public double PositionZ { get; set; } + + [Key(7)] + public double DirectionX { get; set; } + + [Key(8)] + public double DirectionY { get; set; } + + [Key(9)] + public double VelocityX { get; set; } + + [Key(10)] + public double VelocityY { get; set; } + + [Key(11)] + public double Damage { get; set; } + + [Key(12)] + public double Radius { get; set; } + + [Key(13)] + public double AgeSeconds { get; set; } + + [Key(14)] + public double LifetimeSeconds { get; set; } + + [Key(15)] + public bool Guided { get; set; } + + [Key(16)] + public bool Active { get; set; } = true; + + [Key(17)] + public string WeaponKind { get; set; } = ""; } [MessagePackObject] diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeYmirProjectilePhysics.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeYmirProjectilePhysics.cs new file mode 100644 index 000000000..27e4fc336 --- /dev/null +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeYmirProjectilePhysics.cs @@ -0,0 +1,207 @@ +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; } + } +} diff --git a/docs/aetheria-current-codebase-model.md b/docs/aetheria-current-codebase-model.md index 27238a0ce..ab986d262 100644 --- a/docs/aetheria-current-codebase-model.md +++ b/docs/aetheria-current-codebase-model.md @@ -141,6 +141,9 @@ Unity physics is already fenced as forbidden gameplay authority by Current Ymir control flow: - Projectiles ask `AetheriaYmirPhysicsBridge.TryStepProjectile`. +- Daemon RTS combat spawns typed projectile state, but projectile motion and + contact discovery route through `AetheriaRuntimeYmirProjectilePhysics` before + Aetheria applies damage policy. Combat does not own projectile kinematics. - Target/zone hit queries call `TryOverlapTargetHulls`, `TryCastTargetHulls`, `TryOverlapZoneHulls`, or `TryCastZoneHulls`. - `TryBuildDaemonWorld` builds Ymir bodies from `AetheriaDaemonRenderNativeView`