Lower provider-authored combat presentation

This commit is contained in:
Meta Vens 2026-07-15 03:21:28 +01:00
commit d9b762f429
21 changed files with 1019 additions and 76 deletions

View file

@ -190,6 +190,10 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
WitnessReceipt movementReceipt = null;
WitnessReceipt focusReceipt = null;
WitnessReceipt actionReceipt = null;
EveUnityShotReceipt observedShot = null;
EveUnityCombatPresentation observedCombat = null;
float shieldRatioBefore = 0f;
float hullRatioBefore = 0f;
long initialVersion = 0;
float movementDistance = 0f;
@ -231,6 +235,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
provider,
provider);
host.Connect();
host.ShotAvailable += shot => observedShot = shot;
runtime = host.Runtime;
Assert.That(runtime, Is.Not.Null);
Assert.That(runtime.ActiveWorld.PlayerEntityId, Is.Not.Empty);
@ -248,12 +253,6 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
var playerId = runtime.ActiveWorld.PlayerEntityId;
var playerFact = host.PresentedEntities.CurrentGeneration.Entities
.Single(entity => entity.EntityId == playerId);
var targetEntityId = host.PresentedEntities.CurrentGeneration.Entities
.Where(entity => entity.EntityId != playerId)
.Select(entity => entity.EntityId)
.FirstOrDefault();
Assert.That(targetEntityId, Is.Not.Null.And.Not.Empty,
"The advertised live generation has no explicit targeting candidate.");
var playerPrefab = provider.ResolvePrefab(new EveUnityPlayableWorldAssetBinding(
playerFact.AssetRef,
playerFact.EntityKind,
@ -292,13 +291,27 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
Assert.That(movementDistance, Is.GreaterThan(0.01f));
movementReceipt = WitnessReceipt.From("movement", runtime.LastReceipt);
var releaseVersion = runtime.ActiveVersion;
var release = runtime.SubmitMoveVectorIntent(playerId, 0f, 0f, 0f);
var releaseDeadline = Time.realtimeSinceStartup + 12f;
while (Time.realtimeSinceStartup < releaseDeadline &&
(runtime.LastReceipt == null || runtime.LastReceipt.CommandId != release.CommandId ||
!string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase)))
{
yield return new WaitForSecondsRealtime(0.1f);
runtime.Refresh();
}
AssertReconciledProviderReceipt(runtime.LastReceipt, release.CommandId,
provider.Selection.ProviderId, provider.Selection.SurfaceId, releaseVersion);
var movementVersion = runtime.ActiveVersion;
var focus = runtime.SubmitTargetIntent(playerId, targetEntityId);
var focus = runtime.SubmitFocusIntent(playerId);
var focusDeadline = Time.realtimeSinceStartup + 12f;
while (Time.realtimeSinceStartup < focusDeadline &&
(runtime.LastReceipt == null || runtime.LastReceipt.CommandId != focus.CommandId ||
!string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) ||
runtime.ActiveVersion < runtime.LastReceipt.SourceVersion))
runtime.ActiveVersion < runtime.LastReceipt.SourceVersion ||
string.IsNullOrWhiteSpace(EveUnityCombatPresentation.Find(runtime.ActiveProjection)?.SelectedTargetEntityId)))
{
yield return new WaitForSecondsRealtime(0.1f);
runtime.Refresh();
@ -311,12 +324,49 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
movementVersion);
Assert.That(runtime.ActiveVersion, Is.GreaterThanOrEqualTo(runtime.LastReceipt.SourceVersion));
focusReceipt = WitnessReceipt.From("targeting", runtime.LastReceipt);
observedCombat = EveUnityCombatPresentation.Find(runtime.ActiveProjection);
Assert.That(observedCombat, Is.Not.Null);
Assert.That(observedCombat.TargetVisible, Is.True);
Assert.That(observedCombat.TargetHostile, Is.True);
var targetEntityId = observedCombat.SelectedTargetEntityId;
var targetEntityIndex = observedCombat.SelectedTargetEntityIndex;
Assert.That(targetEntityId, Is.Not.Empty);
Assert.That(host.PresentedEntities.CurrentGeneration.Entities.Any(entity => entity.EntityId == targetEntityId), Is.True,
"The provider-selected combat target is absent from the advertised SoA generation.");
shieldRatioBefore = observedCombat.ShieldRatio;
hullRatioBefore = observedCombat.HullRatio;
var targetFact = host.PresentedEntities.CurrentGeneration.Entities
.Single(entity => entity.EntityId == targetEntityId);
playerFact = host.PresentedEntities.CurrentGeneration.Entities
.Single(entity => entity.EntityId == playerId);
var aim = targetFact.Position - playerFact.Position;
aim.y = 0;
Assert.That(aim.sqrMagnitude, Is.GreaterThan(0.0001f));
aim.Normalize();
var steerVersion = runtime.ActiveVersion;
var steer = runtime.SubmitMoveVectorIntent(playerId, aim.x, aim.z, 0.25f);
var steerDeadline = Time.realtimeSinceStartup + 12f;
while (Time.realtimeSinceStartup < steerDeadline &&
(runtime.LastReceipt == null || runtime.LastReceipt.CommandId != steer.CommandId ||
!string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase)))
{
yield return new WaitForSecondsRealtime(0.1f);
runtime.Refresh();
}
AssertReconciledProviderReceipt(runtime.LastReceipt, steer.CommandId,
provider.Selection.ProviderId, provider.Selection.SurfaceId, steerVersion);
var focusVersion = runtime.ActiveVersion;
var action = runtime.SubmitActionIntent(playerId, "0");
var inputDriver = root.GetComponent<EveUnityPlayableWorldInputDriver>() ??
root.AddComponent<EveUnityPlayableWorldInputDriver>();
inputDriver.Host = host;
var action = inputDriver.SubmitPrimaryAction();
Assert.That(action, Is.Not.Null,
"The advertised input capability did not bind a primary action for this generic client.");
var actionDeadline = Time.realtimeSinceStartup + 12f;
while (Time.realtimeSinceStartup < actionDeadline &&
(runtime.LastReceipt == null || runtime.LastReceipt.CommandId != action.CommandId ||
(runtime.LastReceipt == null || runtime.LastReceipt.CommandId != action!.CommandId ||
!string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) ||
runtime.ActiveVersion < runtime.LastReceipt.SourceVersion))
{
@ -325,13 +375,38 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
}
AssertReconciledProviderReceipt(
runtime.LastReceipt,
action.CommandId,
action!.CommandId,
provider.Selection.ProviderId,
provider.Selection.SurfaceId,
focusVersion);
Assert.That(runtime.ActiveVersion, Is.GreaterThanOrEqualTo(runtime.LastReceipt.SourceVersion));
actionReceipt = WitnessReceipt.From("action", runtime.LastReceipt);
var shotDeadline = Time.realtimeSinceStartup + 15f;
while (Time.realtimeSinceStartup < shotDeadline && observedShot == null)
{
yield return new WaitForSecondsRealtime(0.05f);
runtime.Refresh();
}
Assert.That(observedShot, Is.Not.Null,
"A reconciled fire request never reached daemon-owned lock acquisition and shot resolution.");
observedCombat = EveUnityCombatPresentation.Find(runtime.ActiveProjection);
Assert.That(observedShot.TargetEntityIndex, Is.EqualTo(targetEntityIndex));
Assert.That(observedShot.LockQuality, Is.GreaterThan(0.99));
var trajectories = root.GetComponent<EveUnityShotTrajectoryRenderer>();
var combatRenderer = root.GetComponent<EveUnityCombatPresentationRenderer>();
Assert.That(trajectories, Is.Not.Null);
Assert.That(trajectories.ActiveTrajectoryCount, Is.GreaterThan(0));
Assert.That(combatRenderer, Is.Not.Null);
combatRenderer.RefreshNow();
if (observedCombat != null && !string.IsNullOrWhiteSpace(observedCombat.SelectedTargetEntityId))
{
Assert.That(combatRenderer.ReticleVisible, Is.True);
Assert.That(combatRenderer.LockVisible, Is.True);
}
if (observedShot.Hit && (observedShot.AppliedDamage > 0 || observedShot.ShieldAbsorbedDamage > 0))
Assert.That(combatRenderer.HitMarkerVisible, Is.True);
var light = lightObject.AddComponent<Light>();
light.type = LightType.Directional;
light.intensity = 1.4f;
@ -340,17 +415,6 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
camera.clearFlags = CameraClearFlags.SolidColor;
camera.backgroundColor = new Color(0.035f, 0.055f, 0.08f, 1f);
camera.cullingMask = -1;
foreach (var entityMarker in root.GetComponentsInChildren<EveUnityPlayableWorldEntityMarker>())
{
foreach (var renderer in entityMarker.GetComponentsInChildren<Renderer>())
{
renderer.material.color = entityMarker.Controllable
? new Color(0.18f, 0.75f, 1f)
: entityMarker.EntityKind == "enemy"
? new Color(1f, 0.25f, 0.2f)
: new Color(1f, 0.78f, 0.15f);
}
}
var playerMarker = FindMarker(root, playerId);
var playerRenderers = playerMarker.GetComponentsInChildren<Renderer>();
Assert.That(playerRenderers, Is.Not.Empty, "The provider-authored player prefab has no renderable visual.");
@ -387,6 +451,9 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
File.WriteAllBytes(capturePath, pixels.EncodeToPNG());
Assert.That(new FileInfo(capturePath).Length, Is.GreaterThan(1024));
mapRenderers = mapRenderers.Where(renderer => renderer != null).ToList();
Assert.That(mapRenderers, Is.Not.Empty,
"All advertised map-channel renderers were destroyed before map capture.");
var mapBounds = mapRenderers[0].bounds;
foreach (var renderer in mapRenderers)
mapBounds.Encapsulate(renderer.bounds);
@ -420,7 +487,13 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
(mapCamera.cullingMask & (1 << mapLayer)) != 0,
movementReceipt,
focusReceipt,
actionReceipt);
actionReceipt,
observedShot,
shieldRatioBefore,
observedCombat?.ShieldRatio ?? shieldRatioBefore,
hullRatioBefore,
observedCombat?.HullRatio ?? hullRatioBefore,
(float)observedShot.LockQuality);
}
finally
{
@ -451,7 +524,13 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
bool mapCameraIncludesMapChannel,
WitnessReceipt movement,
WitnessReceipt targeting,
WitnessReceipt action)
WitnessReceipt action,
EveUnityShotReceipt shot,
float shieldRatioBefore,
float shieldRatioAfter,
float hullRatioBefore,
float hullRatioAfter,
float lockProgress)
{
var path = Environment.GetEnvironmentVariable("EVEUNITY_WITNESS_FACTS_PATH");
if (string.IsNullOrWhiteSpace(path)) return;
@ -462,6 +541,17 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
movement = movement != null,
targeting = targeting != null,
action = action != null,
combatPresentation = shot != null,
shotId = shot?.ShotId ?? "",
shotHit = shot?.Hit ?? false,
impactKind = shot?.ImpactKind ?? "",
appliedDamage = (float)(shot?.AppliedDamage ?? 0),
shieldAbsorbedDamage = (float)(shot?.ShieldAbsorbedDamage ?? 0),
shieldRatioBefore = shieldRatioBefore,
shieldRatioAfter = shieldRatioAfter,
hullRatioBefore = hullRatioBefore,
hullRatioAfter = hullRatioAfter,
lockProgress = lockProgress,
initialVersion = initialVersion,
finalVersion = finalVersion,
movementDistance = movementDistance,
@ -485,6 +575,17 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
public bool movement;
public bool targeting;
public bool action;
public bool combatPresentation;
public string shotId;
public bool shotHit;
public string impactKind;
public float appliedDamage;
public float shieldAbsorbedDamage;
public float shieldRatioBefore;
public float shieldRatioAfter;
public float hullRatioBefore;
public float hullRatioAfter;
public float lockProgress;
public long initialVersion;
public long finalVersion;
public float movementDistance;

View file

@ -190,6 +190,10 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
WitnessReceipt movementReceipt = null;
WitnessReceipt focusReceipt = null;
WitnessReceipt actionReceipt = null;
EveUnityShotReceipt observedShot = null;
EveUnityCombatPresentation observedCombat = null;
float shieldRatioBefore = 0f;
float hullRatioBefore = 0f;
long initialVersion = 0;
float movementDistance = 0f;
@ -231,6 +235,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
provider,
provider);
host.Connect();
host.ShotAvailable += shot => observedShot = shot;
runtime = host.Runtime;
Assert.That(runtime, Is.Not.Null);
Assert.That(runtime.ActiveWorld.PlayerEntityId, Is.Not.Empty);
@ -248,12 +253,6 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
var playerId = runtime.ActiveWorld.PlayerEntityId;
var playerFact = host.PresentedEntities.CurrentGeneration.Entities
.Single(entity => entity.EntityId == playerId);
var targetEntityId = host.PresentedEntities.CurrentGeneration.Entities
.Where(entity => entity.EntityId != playerId)
.Select(entity => entity.EntityId)
.FirstOrDefault();
Assert.That(targetEntityId, Is.Not.Null.And.Not.Empty,
"The advertised live generation has no explicit targeting candidate.");
var playerPrefab = provider.ResolvePrefab(new EveUnityPlayableWorldAssetBinding(
playerFact.AssetRef,
playerFact.EntityKind,
@ -292,13 +291,27 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
Assert.That(movementDistance, Is.GreaterThan(0.01f));
movementReceipt = WitnessReceipt.From("movement", runtime.LastReceipt);
var releaseVersion = runtime.ActiveVersion;
var release = runtime.SubmitMoveVectorIntent(playerId, 0f, 0f, 0f);
var releaseDeadline = Time.realtimeSinceStartup + 12f;
while (Time.realtimeSinceStartup < releaseDeadline &&
(runtime.LastReceipt == null || runtime.LastReceipt.CommandId != release.CommandId ||
!string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase)))
{
yield return new WaitForSecondsRealtime(0.1f);
runtime.Refresh();
}
AssertReconciledProviderReceipt(runtime.LastReceipt, release.CommandId,
provider.Selection.ProviderId, provider.Selection.SurfaceId, releaseVersion);
var movementVersion = runtime.ActiveVersion;
var focus = runtime.SubmitTargetIntent(playerId, targetEntityId);
var focus = runtime.SubmitFocusIntent(playerId);
var focusDeadline = Time.realtimeSinceStartup + 12f;
while (Time.realtimeSinceStartup < focusDeadline &&
(runtime.LastReceipt == null || runtime.LastReceipt.CommandId != focus.CommandId ||
!string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) ||
runtime.ActiveVersion < runtime.LastReceipt.SourceVersion))
runtime.ActiveVersion < runtime.LastReceipt.SourceVersion ||
string.IsNullOrWhiteSpace(EveUnityCombatPresentation.Find(runtime.ActiveProjection)?.SelectedTargetEntityId)))
{
yield return new WaitForSecondsRealtime(0.1f);
runtime.Refresh();
@ -311,12 +324,49 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
movementVersion);
Assert.That(runtime.ActiveVersion, Is.GreaterThanOrEqualTo(runtime.LastReceipt.SourceVersion));
focusReceipt = WitnessReceipt.From("targeting", runtime.LastReceipt);
observedCombat = EveUnityCombatPresentation.Find(runtime.ActiveProjection);
Assert.That(observedCombat, Is.Not.Null);
Assert.That(observedCombat.TargetVisible, Is.True);
Assert.That(observedCombat.TargetHostile, Is.True);
var targetEntityId = observedCombat.SelectedTargetEntityId;
var targetEntityIndex = observedCombat.SelectedTargetEntityIndex;
Assert.That(targetEntityId, Is.Not.Empty);
Assert.That(host.PresentedEntities.CurrentGeneration.Entities.Any(entity => entity.EntityId == targetEntityId), Is.True,
"The provider-selected combat target is absent from the advertised SoA generation.");
shieldRatioBefore = observedCombat.ShieldRatio;
hullRatioBefore = observedCombat.HullRatio;
var targetFact = host.PresentedEntities.CurrentGeneration.Entities
.Single(entity => entity.EntityId == targetEntityId);
playerFact = host.PresentedEntities.CurrentGeneration.Entities
.Single(entity => entity.EntityId == playerId);
var aim = targetFact.Position - playerFact.Position;
aim.y = 0;
Assert.That(aim.sqrMagnitude, Is.GreaterThan(0.0001f));
aim.Normalize();
var steerVersion = runtime.ActiveVersion;
var steer = runtime.SubmitMoveVectorIntent(playerId, aim.x, aim.z, 0.25f);
var steerDeadline = Time.realtimeSinceStartup + 12f;
while (Time.realtimeSinceStartup < steerDeadline &&
(runtime.LastReceipt == null || runtime.LastReceipt.CommandId != steer.CommandId ||
!string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase)))
{
yield return new WaitForSecondsRealtime(0.1f);
runtime.Refresh();
}
AssertReconciledProviderReceipt(runtime.LastReceipt, steer.CommandId,
provider.Selection.ProviderId, provider.Selection.SurfaceId, steerVersion);
var focusVersion = runtime.ActiveVersion;
var action = runtime.SubmitActionIntent(playerId, "0");
var inputDriver = root.GetComponent<EveUnityPlayableWorldInputDriver>() ??
root.AddComponent<EveUnityPlayableWorldInputDriver>();
inputDriver.Host = host;
var action = inputDriver.SubmitPrimaryAction();
Assert.That(action, Is.Not.Null,
"The advertised input capability did not bind a primary action for this generic client.");
var actionDeadline = Time.realtimeSinceStartup + 12f;
while (Time.realtimeSinceStartup < actionDeadline &&
(runtime.LastReceipt == null || runtime.LastReceipt.CommandId != action.CommandId ||
(runtime.LastReceipt == null || runtime.LastReceipt.CommandId != action!.CommandId ||
!string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) ||
runtime.ActiveVersion < runtime.LastReceipt.SourceVersion))
{
@ -325,13 +375,38 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
}
AssertReconciledProviderReceipt(
runtime.LastReceipt,
action.CommandId,
action!.CommandId,
provider.Selection.ProviderId,
provider.Selection.SurfaceId,
focusVersion);
Assert.That(runtime.ActiveVersion, Is.GreaterThanOrEqualTo(runtime.LastReceipt.SourceVersion));
actionReceipt = WitnessReceipt.From("action", runtime.LastReceipt);
var shotDeadline = Time.realtimeSinceStartup + 15f;
while (Time.realtimeSinceStartup < shotDeadline && observedShot == null)
{
yield return new WaitForSecondsRealtime(0.05f);
runtime.Refresh();
}
Assert.That(observedShot, Is.Not.Null,
"A reconciled fire request never reached daemon-owned lock acquisition and shot resolution.");
observedCombat = EveUnityCombatPresentation.Find(runtime.ActiveProjection);
Assert.That(observedShot.TargetEntityIndex, Is.EqualTo(targetEntityIndex));
Assert.That(observedShot.LockQuality, Is.GreaterThan(0.99));
var trajectories = root.GetComponent<EveUnityShotTrajectoryRenderer>();
var combatRenderer = root.GetComponent<EveUnityCombatPresentationRenderer>();
Assert.That(trajectories, Is.Not.Null);
Assert.That(trajectories.ActiveTrajectoryCount, Is.GreaterThan(0));
Assert.That(combatRenderer, Is.Not.Null);
combatRenderer.RefreshNow();
if (observedCombat != null && !string.IsNullOrWhiteSpace(observedCombat.SelectedTargetEntityId))
{
Assert.That(combatRenderer.ReticleVisible, Is.True);
Assert.That(combatRenderer.LockVisible, Is.True);
}
if (observedShot.Hit && (observedShot.AppliedDamage > 0 || observedShot.ShieldAbsorbedDamage > 0))
Assert.That(combatRenderer.HitMarkerVisible, Is.True);
var light = lightObject.AddComponent<Light>();
light.type = LightType.Directional;
light.intensity = 1.4f;
@ -340,17 +415,6 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
camera.clearFlags = CameraClearFlags.SolidColor;
camera.backgroundColor = new Color(0.035f, 0.055f, 0.08f, 1f);
camera.cullingMask = -1;
foreach (var entityMarker in root.GetComponentsInChildren<EveUnityPlayableWorldEntityMarker>())
{
foreach (var renderer in entityMarker.GetComponentsInChildren<Renderer>())
{
renderer.material.color = entityMarker.Controllable
? new Color(0.18f, 0.75f, 1f)
: entityMarker.EntityKind == "enemy"
? new Color(1f, 0.25f, 0.2f)
: new Color(1f, 0.78f, 0.15f);
}
}
var playerMarker = FindMarker(root, playerId);
var playerRenderers = playerMarker.GetComponentsInChildren<Renderer>();
Assert.That(playerRenderers, Is.Not.Empty, "The provider-authored player prefab has no renderable visual.");
@ -387,6 +451,9 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
File.WriteAllBytes(capturePath, pixels.EncodeToPNG());
Assert.That(new FileInfo(capturePath).Length, Is.GreaterThan(1024));
mapRenderers = mapRenderers.Where(renderer => renderer != null).ToList();
Assert.That(mapRenderers, Is.Not.Empty,
"All advertised map-channel renderers were destroyed before map capture.");
var mapBounds = mapRenderers[0].bounds;
foreach (var renderer in mapRenderers)
mapBounds.Encapsulate(renderer.bounds);
@ -420,7 +487,13 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
(mapCamera.cullingMask & (1 << mapLayer)) != 0,
movementReceipt,
focusReceipt,
actionReceipt);
actionReceipt,
observedShot,
shieldRatioBefore,
observedCombat?.ShieldRatio ?? shieldRatioBefore,
hullRatioBefore,
observedCombat?.HullRatio ?? hullRatioBefore,
(float)observedShot.LockQuality);
}
finally
{
@ -451,7 +524,13 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
bool mapCameraIncludesMapChannel,
WitnessReceipt movement,
WitnessReceipt targeting,
WitnessReceipt action)
WitnessReceipt action,
EveUnityShotReceipt shot,
float shieldRatioBefore,
float shieldRatioAfter,
float hullRatioBefore,
float hullRatioAfter,
float lockProgress)
{
var path = Environment.GetEnvironmentVariable("EVEUNITY_WITNESS_FACTS_PATH");
if (string.IsNullOrWhiteSpace(path)) return;
@ -462,6 +541,17 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
movement = movement != null,
targeting = targeting != null,
action = action != null,
combatPresentation = shot != null,
shotId = shot?.ShotId ?? "",
shotHit = shot?.Hit ?? false,
impactKind = shot?.ImpactKind ?? "",
appliedDamage = (float)(shot?.AppliedDamage ?? 0),
shieldAbsorbedDamage = (float)(shot?.ShieldAbsorbedDamage ?? 0),
shieldRatioBefore = shieldRatioBefore,
shieldRatioAfter = shieldRatioAfter,
hullRatioBefore = hullRatioBefore,
hullRatioAfter = hullRatioAfter,
lockProgress = lockProgress,
initialVersion = initialVersion,
finalVersion = finalVersion,
movementDistance = movementDistance,
@ -485,6 +575,17 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
public bool movement;
public bool targeting;
public bool action;
public bool combatPresentation;
public string shotId;
public bool shotHit;
public string impactKind;
public float appliedDamage;
public float shieldAbsorbedDamage;
public float shieldRatioBefore;
public float shieldRatioAfter;
public float hullRatioBefore;
public float hullRatioAfter;
public float lockProgress;
public long initialVersion;
public long finalVersion;
public float movementDistance;

View file

@ -35,6 +35,14 @@ Retained provider feedback is lowered without becoming Unity authority.
The generic `EveUnityShotTrajectoryRenderer` renders provider-authored origin,
endpoint, duration, and hit outcome, then expires the visual locally. It never
uses Unity collision to infer damage or report a hit back to the provider.
Providers may publish a non-spatial `combat.presentation` component beside the
SoA body view. `EveUnityCombatPresentationRenderer` lowers its selected target,
contact visibility, lock progress, meter ratios, semantic roles, and timing
into reticle, lock, shield/hull, and hit-marker visuals. Hit markers require a
new deduplicated shot receipt from the advertised controlled source to the
advertised selection with authoritative applied or shield-absorbed damage.
SoA remains the only transform authority; the presentation component never
duplicates bodies or computes combat outcomes.
The reusable `LightningCompute` renderer and its shader/material bundle live in
this runtime package with their original Unity GUIDs. Provider asset bundles may
therefore reference the effect without importing Aetheria gameplay code.

View file

@ -25,6 +25,7 @@ namespace GameCult.Eve.UnityScene
{
typeof(EveProviderAdvertisementDocument),
typeof(EveSurfaceDocument),
typeof(EveInputCapabilityDocument),
typeof(EveSurfaceCommandRequest),
typeof(EveCommandReceiptDocument),
typeof(EveAssetCatalogDocument),
@ -86,6 +87,7 @@ namespace GameCult.Eve.UnityScene
_bodyResolver = bodyResolver;
CurrentSurfaceDocument = EmptySurfaceDocument();
CurrentAssetManifestDocument = EmptyAssetManifest();
CurrentInputCapability = new EveInputCapabilityDocument();
}
public string TransportKind => "eve-cultmesh-remote-replica";
@ -98,6 +100,8 @@ namespace GameCult.Eve.UnityScene
public EveUnityPlayableWorldAssetManifestDocument CurrentAssetManifestDocument { get; private set; }
public EveInputCapabilityDocument CurrentInputCapability { get; private set; }
public event Action<EveUnitySceneProviderSurfaceDocument>? SurfaceDocumentAvailable;
public event Action<EveUnityPlayableWorldAssetManifestDocument>? AssetManifestDocumentAvailable;
@ -132,9 +136,41 @@ namespace GameCult.Eve.UnityScene
EnsureOpen();
ResolveAdvertisement(forceRefresh: true);
RefreshSurface();
RefreshInputCapability();
RefreshAssetCatalog();
}
private void RefreshInputCapability()
{
var recordRef = FindComponentProp(
CurrentSurfaceDocument.SurfaceDocument.Surface.Root,
"inputCapability");
if (string.IsNullOrWhiteSpace(recordRef))
throw new InvalidOperationException("The advertised playable world does not publish an input capability record.");
CurrentInputCapability = _snapshot!
.FetchDocumentsAsync<EveInputCapabilityDocument>(
recordKeys: new[] { recordRef },
schemaIds: new[] { EveInputCapabilityDocument.SchemaId })
.GetAwaiter()
.GetResult()
.FirstOrDefault()
?? throw new InvalidOperationException($"The advertised input capability '{recordRef}' was not available.");
}
private static string FindComponentProp(EveSurfaceComponent component, string key)
{
var value = component.GetProp(key);
if (!string.IsNullOrWhiteSpace(value))
return value;
foreach (var child in component.Children)
{
value = FindComponentProp(child, key);
if (!string.IsNullOrWhiteSpace(value))
return value;
}
return "";
}
public void PumpLiveEvents()
{
PollCurrentEntityView();

View file

@ -18,7 +18,8 @@ namespace GameCult.Eve.UnityScene
IEveUnityProviderRefreshSource,
IEveUnityEntitySoaViewDocumentSource,
IEveUnityCameraRenderPolicySource,
IEveUnityNativeAssetProvider
IEveUnityNativeAssetProvider,
IEveUnityInputCapabilitySource
{
[SerializeField] private string rendezvousEndpoint = "";
[SerializeField] private string providerFilter = "";
@ -40,6 +41,15 @@ namespace GameCult.Eve.UnityScene
public EveUnitySceneProviderSurfaceDocument CurrentDocument => Bridge.CurrentSurfaceDocument;
public EveInputCapabilityDocument CurrentInputCapability
{
get
{
EnsureTransport();
return _transport!.CurrentInputCapability;
}
}
public event Action<EveUnitySceneProviderSurfaceDocument>? DocumentAvailable
{
add { Bridge.DocumentAvailable += value; }

View file

@ -0,0 +1,98 @@
using System;
using System.Collections.Generic;
using System.Globalization;
#nullable enable
namespace GameCult.Eve.UnityScene
{
public sealed class EveUnityCombatPresentation
{
public EveUnityCombatPresentation(EveUnitySceneNode node)
{
if (node == null) throw new ArgumentNullException(nameof(node));
ControlledEntityId = Get(node, "controlledEntityId");
ControlledEntityIndex = Int(node, "controlledEntityIndex", -1);
SelectedTargetEntityId = Get(node, "selectedTargetEntityId");
SelectedTargetEntityIndex = Int(node, "selectedTargetEntityIndex", -1);
TargetVisible = Bool(node, "targetVisible");
TargetHostile = Bool(node, "targetHostile");
ContactInformation = Ratio(node, "contactInformation");
ShieldRatio = Ratio(node, "shieldRatio");
HullRatio = Ratio(node, "hullRatio");
LockProgress = Ratio(node, "lockProgress");
ReticleRole = Get(node, "reticleRole");
LockRole = Get(node, "lockRole");
ShieldMeterRole = Get(node, "shieldMeterRole");
HullMeterRole = Get(node, "hullMeterRole");
HitMarkerRole = Get(node, "hitMarkerRole");
LockDisplayThreshold = Ratio(node, "lockDisplayThreshold", 0.01f);
HitMarkerDurationSeconds = Math.Max(0.01f, Number(node, "hitMarkerDurationSeconds", 0.25f));
RadialFillMinimum = Ratio(node, "radialFillMinimum", 0.25f);
RadialFillMaximum = Ratio(node, "radialFillMaximum", 0.75f);
}
public string ControlledEntityId { get; }
public int ControlledEntityIndex { get; }
public string SelectedTargetEntityId { get; }
public int SelectedTargetEntityIndex { get; }
public bool TargetVisible { get; }
public bool TargetHostile { get; }
public float ContactInformation { get; }
public float ShieldRatio { get; }
public float HullRatio { get; }
public float LockProgress { get; }
public string ReticleRole { get; }
public string LockRole { get; }
public string ShieldMeterRole { get; }
public string HullMeterRole { get; }
public string HitMarkerRole { get; }
public float LockDisplayThreshold { get; }
public float HitMarkerDurationSeconds { get; }
public float RadialFillMinimum { get; }
public float RadialFillMaximum { get; }
public float RadialFill(float ratio) =>
RadialFillMinimum + (RadialFillMaximum - RadialFillMinimum) * Math.Max(0, Math.Min(1, ratio));
public bool PresentsHit(EveUnityShotReceipt receipt) =>
receipt != null &&
receipt.SourceEntityIndex == ControlledEntityIndex &&
receipt.TargetEntityIndex == SelectedTargetEntityIndex &&
receipt.Hit &&
(receipt.AppliedDamage > 0 || receipt.ShieldAbsorbedDamage > 0);
public static EveUnityCombatPresentation? Find(EveUnitySceneProjection? projection)
{
if (projection == null) return null;
return Find(projection.Root);
}
private static EveUnityCombatPresentation? Find(EveUnitySceneNode node)
{
if (string.Equals(node.ComponentKind, "combat.presentation", StringComparison.Ordinal))
return new EveUnityCombatPresentation(node);
foreach (var child in node.Children)
{
var found = Find(child);
if (found != null) return found;
}
return null;
}
private static string Get(EveUnitySceneNode node, string key) =>
node.Props.TryGetValue(key, out var value) ? value ?? "" : "";
private static int Int(EveUnitySceneNode node, string key, int fallback) =>
int.TryParse(Get(node, key), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : fallback;
private static bool Bool(EveUnitySceneNode node, string key) =>
bool.TryParse(Get(node, key), out var value) && value;
private static float Number(EveUnitySceneNode node, string key, float fallback = 0) =>
float.TryParse(Get(node, key), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? value : fallback;
private static float Ratio(EveUnitySceneNode node, string key, float fallback = 0) =>
Math.Max(0, Math.Min(1, Number(node, key, fallback)));
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e15acab8e9094b18b6dbd3abea0e055d

View file

@ -0,0 +1,262 @@
using System;
using UnityEngine;
#nullable enable
namespace GameCult.Eve.UnityScene
{
public sealed class EveUnityCombatPresentationRenderer : MonoBehaviour
{
[SerializeField] private Color reticleColor = new Color(0.2f, 0.9f, 1f, 0.9f);
[SerializeField] private Color lockColor = new Color(1f, 0.7f, 0.15f, 1f);
[SerializeField] private Color shieldColor = new Color(0.15f, 0.65f, 1f, 1f);
[SerializeField] private Color hullColor = new Color(1f, 0.35f, 0.2f, 1f);
[SerializeField] private Color hitColor = Color.white;
[SerializeField] private float width = 0.12f;
private EveUnityPlayableWorldClientHost? _host;
private Transform? _visualRoot;
private LineRenderer? _reticle;
private LineRenderer? _lock;
private LineRenderer? _shield;
private LineRenderer? _hull;
private LineRenderer? _hitA;
private LineRenderer? _hitB;
private Material? _material;
private float _hitUntil;
private EveUnityCombatPresentation? _lastPresentation;
private Vector3 _lastCenter;
private Vector3 _lastRight;
private Vector3 _lastUp;
private float _lastRadius;
private bool _hasLastTarget;
public EveUnityCombatPresentation? Current { get; private set; }
public bool ReticleVisible => _reticle != null && _reticle.enabled;
public bool LockVisible => _lock != null && _lock.enabled;
public bool HitMarkerVisible => _hitA != null && _hitA.enabled;
public void Bind(EveUnityPlayableWorldClientHost host)
{
if (host == null) throw new ArgumentNullException(nameof(host));
if (ReferenceEquals(_host, host)) return;
Unbind();
_host = host;
_host.ShotAvailable += OnShotAvailable;
}
public void Unbind()
{
if (_host != null) _host.ShotAvailable -= OnShotAvailable;
_host = null;
Current = null;
SetVisible(false);
}
public void RefreshNow()
{
Current = EveUnityCombatPresentation.Find(_host?.ActiveProjection);
if (Current == null || string.IsNullOrWhiteSpace(Current.SelectedTargetEntityId))
{
PresentRetainedHit();
return;
}
var marker = FindMarker(Current.SelectedTargetEntityId);
if (marker == null)
{
PresentRetainedHit();
return;
}
_lastPresentation = Current;
EnsureVisuals();
PresentationAxes(out var right, out var up, out var towardCamera, out var cullingMask);
var visualBounds = VisualBounds(marker.transform, cullingMask);
var center = visualBounds?.center ?? (marker.transform.position + Vector3.up * 0.25f);
var radius = Math.Max(0.8f, visualBounds?.extents.magnitude ?? marker.transform.lossyScale.magnitude * 0.8f);
center += towardCamera * 0.1f;
_lastCenter = center;
_lastRight = right;
_lastUp = up;
_lastRadius = radius;
_hasLastTarget = true;
Arc(_reticle!, center, right, up, radius * 1.35f, 1f, 0f);
_reticle!.enabled = Current.TargetVisible;
var lockVisible = Current.TargetVisible && Current.TargetHostile &&
Current.LockProgress > Current.LockDisplayThreshold;
var noise = (1f - Current.LockProgress) * radius * 0.2f;
var frequency = Mathf.Lerp(0.01f, 10f, Mathf.Pow(Current.LockProgress, 4f));
var noisyCenter = center +
right * ((Mathf.PerlinNoise(Time.unscaledTime * frequency, 0.37f) - 0.5f) * noise) +
up * ((Mathf.PerlinNoise(0.73f, Time.unscaledTime * frequency) - 0.5f) * noise);
var spin = Time.unscaledTime * Mathf.Lerp(5f, 720f, Current.LockProgress * Current.LockProgress);
Arc(_lock!, noisyCenter, right, up, radius * 1.55f, Current.LockProgress, spin);
_lock!.enabled = lockVisible;
Arc(_shield!, center, right, up, radius * 1.78f, Current.RadialFill(Current.ShieldRatio), -90f);
Arc(_hull!, center, right, up, radius * 1.98f, Current.RadialFill(Current.HullRatio), -90f);
_shield!.enabled = Current.TargetVisible;
_hull!.enabled = Current.TargetVisible;
var hitVisible = Time.unscaledTime < _hitUntil;
Cross(_hitA!, _hitB!, center, right, up, radius * 0.55f);
_hitA!.enabled = hitVisible;
_hitB!.enabled = hitVisible;
}
private void PresentRetainedHit()
{
if (!_hasLastTarget || Time.unscaledTime >= _hitUntil)
{
SetVisible(false);
return;
}
EnsureVisuals();
_reticle!.enabled = _lock!.enabled = _shield!.enabled = _hull!.enabled = false;
Cross(_hitA!, _hitB!, _lastCenter, _lastRight, _lastUp, _lastRadius * 0.55f);
_hitA!.enabled = _hitB!.enabled = true;
}
private void LateUpdate() => RefreshNow();
private void OnShotAvailable(EveUnityShotReceipt receipt)
{
Current = EveUnityCombatPresentation.Find(_host?.ActiveProjection);
var presentation = Current;
if (presentation?.PresentsHit(receipt) != true)
presentation = _lastPresentation;
if (presentation?.PresentsHit(receipt) == true)
_hitUntil = Time.unscaledTime + presentation.HitMarkerDurationSeconds;
}
private EveUnityPlayableWorldEntityMarker? FindMarker(string entityId)
{
if (_host == null) return null;
foreach (var marker in _host.SceneRoot.GetComponentsInChildren<EveUnityPlayableWorldEntityMarker>(true))
if (string.Equals(marker.EntityId, entityId, StringComparison.Ordinal)) return marker;
return null;
}
private void EnsureVisuals()
{
if (_visualRoot != null) return;
var root = new GameObject("Eve combat presentation");
root.transform.SetParent(transform, false);
_visualRoot = root.transform;
_reticle = Line("Selected target reticle", reticleColor, width);
_lock = Line("Target lock", lockColor, width * 1.15f);
_shield = Line("Target shield", shieldColor, width * 0.75f);
_hull = Line("Target hull", hullColor, width * 0.75f);
_hitA = Line("Hit marker A", hitColor, width * 1.3f);
_hitB = Line("Hit marker B", hitColor, width * 1.3f);
}
private LineRenderer Line(string name, Color color, float lineWidth)
{
var child = new GameObject(name);
child.transform.SetParent(_visualRoot, false);
var line = child.AddComponent<LineRenderer>();
line.useWorldSpace = true;
line.loop = false;
line.material = SharedMaterial();
line.startColor = line.endColor = color;
line.startWidth = line.endWidth = Math.Max(0.01f, lineWidth);
line.numCapVertices = 2;
return line;
}
private Material SharedMaterial()
{
if (_material != null) return _material;
var shader = Shader.Find("Sprites/Default") ?? Shader.Find("Universal Render Pipeline/Unlit");
if (shader == null) throw new InvalidOperationException("EveUnity requires an unlit shader for combat presentation.");
_material = new Material(shader) { name = "EveUnity combat presentation" };
return _material;
}
private static void PresentationAxes(out Vector3 right, out Vector3 up, out Vector3 towardCamera, out int cullingMask)
{
Camera? selected = Camera.main;
if (selected == null)
{
foreach (var candidate in Camera.allCameras)
{
if (!candidate.isActiveAndEnabled || candidate.orthographic)
continue;
selected = candidate;
break;
}
}
right = selected == null ? Vector3.right : selected.transform.right;
up = selected == null ? Vector3.up : selected.transform.up;
towardCamera = selected == null ? Vector3.back : -selected.transform.forward;
cullingMask = selected == null ? -1 : selected.cullingMask;
}
private static Bounds? VisualBounds(Transform root, int cullingMask)
{
Bounds? bounds = null;
foreach (var renderer in root.GetComponentsInChildren<Renderer>(includeInactive: false))
{
if (renderer == null || !renderer.enabled)
continue;
if ((cullingMask & (1 << renderer.gameObject.layer)) == 0)
continue;
if (renderer is LineRenderer || renderer is TrailRenderer ||
string.Equals(renderer.GetType().Name, "ParticleSystemRenderer", StringComparison.Ordinal))
continue;
if (bounds.HasValue)
{
var combined = bounds.Value;
combined.Encapsulate(renderer.bounds);
bounds = combined;
}
else
{
bounds = renderer.bounds;
}
}
return bounds;
}
private static void Arc(LineRenderer line, Vector3 center, Vector3 right, Vector3 up, float radius, float fill, float rotationDegrees)
{
fill = Mathf.Clamp01(fill);
var segments = Math.Max(2, Mathf.CeilToInt(64 * fill) + 1);
line.positionCount = segments;
for (var index = 0; index < segments; index++)
{
var progress = segments <= 1 ? 0 : index / (float)(segments - 1);
var angle = (rotationDegrees + progress * 360f * fill) * Mathf.Deg2Rad;
line.SetPosition(index, center + right * (Mathf.Cos(angle) * radius) + up * (Mathf.Sin(angle) * radius));
}
}
private static void Cross(LineRenderer a, LineRenderer b, Vector3 center, Vector3 right, Vector3 up, float radius)
{
a.positionCount = b.positionCount = 2;
a.SetPosition(0, center - right * radius - up * radius);
a.SetPosition(1, center + right * radius + up * radius);
b.SetPosition(0, center - right * radius + up * radius);
b.SetPosition(1, center + right * radius - up * radius);
}
private void SetVisible(bool visible)
{
if (_reticle != null) _reticle.enabled = visible;
if (_lock != null) _lock.enabled = visible;
if (_shield != null) _shield.enabled = visible;
if (_hull != null) _hull.enabled = visible;
if (_hitA != null) _hitA.enabled = visible && Time.unscaledTime < _hitUntil;
if (_hitB != null) _hitB.enabled = visible && Time.unscaledTime < _hitUntil;
}
private void OnDestroy()
{
Unbind();
if (_material != null) Destroy(_material);
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5c953d99a7454322b2935b878295b357

View file

@ -71,7 +71,8 @@ namespace GameCult.Eve.UnityScene
}
var bounds = CalculateVisualBounds(
player,
cameraComponent != null ? cameraComponent.cullingMask : -1);
cameraComponent != null ? cameraComponent.cullingMask : -1,
FindSelectedTarget(resolvedHost));
var target = bounds?.center ?? player.position;
var radius = bounds?.extents.magnitude ?? 0f;
var verticalFov = cameraComponent != null ? cameraComponent.fieldOfView : 60f;
@ -91,29 +92,47 @@ namespace GameCult.Eve.UnityScene
return true;
}
private static Bounds? CalculateVisualBounds(Transform player, int cullingMask)
private static Bounds? CalculateVisualBounds(Transform player, int cullingMask, Transform? selectedTarget)
{
var renderers = player.GetComponentsInChildren<Renderer>(includeInactive: false);
Bounds? bounds = null;
foreach (var visual in renderers)
foreach (var root in selectedTarget == null
? new[] { player }
: new[] { player, selectedTarget! })
{
if (visual == null || !visual.enabled ||
(cullingMask & (1 << visual.gameObject.layer)) == 0)
continue;
if (bounds.HasValue)
foreach (var visual in root.GetComponentsInChildren<Renderer>(includeInactive: false))
{
var combined = bounds.Value;
combined.Encapsulate(visual.bounds);
bounds = combined;
}
else
{
bounds = visual.bounds;
if (visual == null || !visual.enabled ||
(cullingMask & (1 << visual.gameObject.layer)) == 0)
continue;
if (visual is LineRenderer || visual is TrailRenderer ||
string.Equals(visual.GetType().Name, "ParticleSystemRenderer", System.StringComparison.Ordinal))
continue;
if (bounds.HasValue)
{
var combined = bounds.Value;
combined.Encapsulate(visual.bounds);
bounds = combined;
}
else
{
bounds = visual.bounds;
}
}
}
return bounds;
}
private static Transform? FindSelectedTarget(EveUnityPlayableWorldClientHost host)
{
var combat = EveUnityCombatPresentation.Find(host.ActiveProjection);
if (combat == null || string.IsNullOrWhiteSpace(combat.SelectedTargetEntityId))
return null;
if (host.PresentedEntities?.CurrentGeneration != null &&
host.PresentedEntities.TryGetByEntityId(combat.SelectedTargetEntityId, out var presented))
return presented.Transform;
return null;
}
private void LateUpdate()
{
if (driveInLateUpdate)

View file

@ -33,6 +33,9 @@ namespace GameCult.Eve.UnityScene
public EveUnitySceneCommandReceipt? LastReceipt => Runtime?.LastReceipt;
public IEveUnityPresentedEntityRegistry? PresentedEntities => Runtime?.PresentedEntities;
public EveInputCapabilityDocument? InputCapability =>
(providerSurfaceDocuments as IEveUnityInputCapabilitySource)?.CurrentInputCapability;
public long ActiveVersion => Runtime?.ActiveVersion ?? 0;
public event Action<EveUnityFeedbackEvent>? FeedbackAvailable;
@ -89,6 +92,9 @@ namespace GameCult.Eve.UnityScene
var feedbackEffects = GetComponent<EveUnityFeedbackEffectRenderer>();
if (feedbackEffects == null) feedbackEffects = gameObject.AddComponent<EveUnityFeedbackEffectRenderer>();
feedbackEffects.Bind(this, Runtime.GameObjectAssetProvider);
var combatPresentation = GetComponent<EveUnityCombatPresentationRenderer>();
if (combatPresentation == null) combatPresentation = gameObject.AddComponent<EveUnityCombatPresentationRenderer>();
combatPresentation.Bind(this);
var thermal = GetComponent<EveUnityThermalPresenter>();
var thermalHud = GetComponent<EveUnityThermalHudSink>();
if (thermalHud == null) thermalHud = gameObject.AddComponent<EveUnityThermalHudSink>();

View file

@ -1,4 +1,5 @@
using System;
using System.Linq;
using GameCult.Eve.Surface;
using UnityEngine;
@ -6,6 +7,11 @@ using UnityEngine;
namespace GameCult.Eve.UnityScene
{
public interface IEveUnityInputCapabilitySource
{
EveInputCapabilityDocument CurrentInputCapability { get; }
}
public sealed class EveUnityPlayableWorldInputDriver : MonoBehaviour
{
[SerializeField] private EveUnityPlayableWorldClientHost? host;
@ -71,15 +77,41 @@ namespace GameCult.Eve.UnityScene
issuedAt);
}
public EveSurfaceCommandRequest? SubmitPrimaryAction(string actionId = "primary")
public EveSurfaceCommandRequest? SubmitPrimaryAction(string actionId = "")
{
var resolvedHost = ResolveHost();
if (resolvedHost?.ActiveWorld == null)
return null;
var resolvedActionId = string.IsNullOrWhiteSpace(actionId)
? ResolvePrimaryActionId(resolvedHost.InputCapability)
: actionId;
if (string.IsNullOrWhiteSpace(resolvedActionId))
return null;
return resolvedHost.SubmitActionIntent(
resolvedHost.ActiveWorld.PlayerEntityId,
string.IsNullOrWhiteSpace(actionId) ? "primary" : actionId);
resolvedActionId);
}
public static string ResolvePrimaryActionId(EveInputCapabilityDocument? capability)
{
if (capability == null)
return "";
var profile = (capability.DefaultProfiles ?? Array.Empty<EveInputProfileDocument>())
.FirstOrDefault(value => string.Equals(value.DeviceClass, "keyboard-mouse", StringComparison.Ordinal))
?? (capability.DefaultProfiles ?? Array.Empty<EveInputProfileDocument>()).FirstOrDefault();
var binding = (profile?.Bindings ?? Array.Empty<EveInputBindingDocument>())
.FirstOrDefault(value => (value.Gesture?.Controls ?? Array.Empty<string>())
.Any(control => string.Equals(control, "mouse.primary", StringComparison.Ordinal)))
?? (profile?.Bindings ?? Array.Empty<EveInputBindingDocument>()).FirstOrDefault(value => value.ActionBar);
if (binding == null)
return "";
var action = (capability.Actions ?? Array.Empty<EveInputActionDocument>())
.FirstOrDefault(value => string.Equals(value.ActionId, binding.ActionId, StringComparison.Ordinal));
return action != null && string.Equals(action.Availability, "available", StringComparison.OrdinalIgnoreCase)
? action.ActionId
: "";
}
private void Update()
@ -91,7 +123,7 @@ namespace GameCult.Eve.UnityScene
SubmitMoveVectorInput(Input.GetAxisRaw(horizontalAxis), Input.GetAxisRaw(verticalAxis));
if (!string.IsNullOrWhiteSpace(actionButton) && Input.GetButtonDown(actionButton))
SubmitPrimaryAction(actionButton);
SubmitPrimaryAction();
}
private EveUnityPlayableWorldClientHost? ResolveHost()

View file

@ -38,6 +38,9 @@ namespace GameCult.Eve.UnityScene
ItemKey = Get(node, "itemKey");
ImpactKind = Get(node, "impactKind");
PresentationIntensity = Math.Max(0, Double(node, "presentationIntensity", 1));
AppliedDamage = Math.Max(0, Double(node, "appliedDamage", 0));
ShieldAbsorbedDamage = Math.Max(0, Double(node, "shieldAbsorbedDamage", 0));
LockQuality = Math.Max(0, Math.Min(1, Double(node, "lockQuality", 0)));
}
public string ShotId { get; }
@ -54,6 +57,9 @@ namespace GameCult.Eve.UnityScene
public string ItemKey { get; }
public string ImpactKind { get; }
public double PresentationIntensity { get; }
public double AppliedDamage { get; }
public double ShieldAbsorbedDamage { get; }
public double LockQuality { get; }
private static string Get(EveUnitySceneNode node, string key) =>
node.Props.TryGetValue(key, out var value) ? value ?? "" : "";

View file

@ -0,0 +1,83 @@
using System;
using System.Collections.Generic;
using NUnit.Framework;
#nullable enable
namespace GameCult.Eve.UnityScene.Tests
{
public sealed class EveUnityCombatPresentationTests
{
[Test]
public void ProjectionPreservesProviderSelectionLockMetersAndFossilTiming()
{
var projection = Projection(Combat());
var combat = EveUnityCombatPresentation.Find(projection);
Assert.That(combat, Is.Not.Null);
Assert.That(combat!.ControlledEntityIndex, Is.EqualTo(4));
Assert.That(combat.SelectedTargetEntityId, Is.EqualTo("run.zone.0.entity.9"));
Assert.That(combat.TargetVisible, Is.True);
Assert.That(combat.TargetHostile, Is.True);
Assert.That(combat.LockProgress, Is.EqualTo(0.6f).Within(0.0001f));
Assert.That(combat.RadialFill(0), Is.EqualTo(0.25f).Within(0.0001f));
Assert.That(combat.RadialFill(1), Is.EqualTo(0.75f).Within(0.0001f));
Assert.That(combat.HitMarkerDurationSeconds, Is.EqualTo(0.25f));
}
[Test]
public void HitMarkerRequiresControlledSourceSelectedTargetAndAuthoritativeDamage()
{
var combat = new EveUnityCombatPresentation(Combat());
Assert.That(combat.PresentsHit(Shot(4, 9, true, 12, 0)), Is.True);
Assert.That(combat.PresentsHit(Shot(3, 9, true, 12, 0)), Is.False);
Assert.That(combat.PresentsHit(Shot(4, 8, true, 12, 0)), Is.False);
Assert.That(combat.PresentsHit(Shot(4, 9, false, 0, 0)), Is.False);
Assert.That(combat.PresentsHit(Shot(4, 9, true, 0, 0)), Is.False);
Assert.That(combat.PresentsHit(Shot(4, 9, true, 0, 12)), Is.True);
}
private static EveUnitySceneProjection Projection(params EveUnitySceneNode[] children) =>
new EveUnitySceneProjection("provider", "surface", "world", "commands", "receipt", "provider", null,
Node("root", "surface", children));
private static EveUnitySceneNode Combat() => Node("combat", "combat.presentation", new Dictionary<string, string>(StringComparer.Ordinal)
{
["controlledEntityId"] = "run.zone.0.entity.4",
["controlledEntityIndex"] = "4",
["selectedTargetEntityId"] = "run.zone.0.entity.9",
["selectedTargetEntityIndex"] = "9",
["targetVisible"] = "true",
["targetHostile"] = "true",
["contactInformation"] = "0.8",
["shieldRatio"] = "0.4",
["hullRatio"] = "0.9",
["lockProgress"] = "0.6",
["hitMarkerDurationSeconds"] = "0.25",
["radialFillMinimum"] = "0.25",
["radialFillMaximum"] = "0.75"
});
private static EveUnityShotReceipt Shot(int source, int target, bool hit, double damage, double shield) =>
new EveUnityShotReceipt(Node("shot", "shot.receipt", new Dictionary<string, string>(StringComparer.Ordinal)
{
["shotId"] = Guid.NewGuid().ToString("N"),
["sourceEntityIndex"] = source.ToString(),
["targetEntityIndex"] = target.ToString(),
["hit"] = hit.ToString(),
["appliedDamage"] = damage.ToString(System.Globalization.CultureInfo.InvariantCulture),
["shieldAbsorbedDamage"] = shield.ToString(System.Globalization.CultureInfo.InvariantCulture)
}));
private static EveUnitySceneNode Node(string id, string kind, params EveUnitySceneNode[] children) =>
Node(id, kind, Empty(), children);
private static EveUnitySceneNode Node(string id, string kind, IReadOnlyDictionary<string, string> props, params EveUnitySceneNode[] children) =>
new EveUnitySceneNode(id, kind, "scene-node", props, Empty(), Empty(), 0, 0,
Array.Empty<EveUnitySceneEmbeddedDocumentSlot>(), null, children);
private static IReadOnlyDictionary<string, string> Empty() =>
new Dictionary<string, string>(StringComparer.Ordinal);
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 30c8f313c1ea4b43858ed212909b7ce9

View file

@ -978,6 +978,48 @@ namespace GameCult.Eve.UnityScene.Tests
}
}
[Test]
public void PlayableWorldInputDriverUsesAdvertisedPrimaryBinding()
{
var capability = new EveInputCapabilityDocument
{
Actions = new[]
{
new EveInputActionDocument
{
ActionId = "weapon-group.0.fire",
Availability = "available"
}
},
DefaultProfiles = new[]
{
new EveInputProfileDocument
{
ProfileId = "keyboard-mouse",
DeviceClass = "keyboard-mouse",
Bindings = new[]
{
new EveInputBindingDocument
{
ActionId = "weapon-group.0.fire",
Gesture = new EveInputGestureDocument
{
Controls = new[] { "mouse.primary" }
},
ActionBar = true
}
}
}
}
};
Assert.That(
EveUnityPlayableWorldInputDriver.ResolvePrimaryActionId(capability),
Is.EqualTo("weapon-group.0.fire"));
capability.Actions[0].Availability = "unavailable";
Assert.That(EveUnityPlayableWorldInputDriver.ResolvePrimaryActionId(capability), Is.Empty);
}
[Test]
public void PlayableWorldCameraRigFollowsAdvertisedPlayerEntityWithoutProviderTypes()
{

View file

@ -34,6 +34,8 @@ namespace GameCult.Eve.UnityScene.Tests
Assert.That(emitted[0].ItemKey, Is.EqualTo("test-cannon"));
Assert.That(emitted[0].ImpactKind, Is.EqualTo("shield"));
Assert.That(emitted[0].PresentationIntensity, Is.EqualTo(9));
Assert.That(emitted[0].AppliedDamage, Is.EqualTo(12));
Assert.That(emitted[0].ShieldAbsorbedDamage, Is.EqualTo(8));
Assert.That(presenter.Apply(next), Is.Zero);
}
@ -69,7 +71,9 @@ namespace GameCult.Eve.UnityScene.Tests
["presentationKind"] = "test-laser",
["itemKey"] = "test-cannon",
["impactKind"] = "shield",
["presentationIntensity"] = "9"
["presentationIntensity"] = "9",
["appliedDamage"] = "12",
["shieldAbsorbedDamage"] = "8"
}, Empty(), Empty(), 0, 0, Array.Empty<EveUnitySceneEmbeddedDocumentSlot>(), null,
Array.Empty<EveUnitySceneNode>());

View file

@ -114,6 +114,8 @@
"packages/org.gamecult.eve.unity-scene/Runtime/EveUnityFeedbackPresenter.cs",
"packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotReceiptPresenter.cs",
"packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotTrajectoryRenderer.cs",
"packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentation.cs",
"packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentationRenderer.cs",
"packages/org.gamecult.eve.unity-scene/Runtime/EveUnityProviderRefreshSource.cs",
"packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldPresenter.cs",
"packages/org.gamecult.eve.unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs",
@ -121,7 +123,8 @@
"packages/org.gamecult.eve.unity-scene/Runtime/SaiVisualNovelUnitySceneProjectionAdapter.cs",
"packages/org.gamecult.eve.unity-scene/Runtime/NornGraphUnitySceneProjectionAdapter.cs",
"packages/org.gamecult.eve.unity-scene/Runtime/TeXMathUnitySceneProjectionAdapter.cs",
"packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs"
"packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs",
"packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCombatPresentationTests.cs"
]
}
],

View file

@ -1,7 +1,7 @@
{
"name": "org.gamecult.eve.unity-scene",
"displayName": "Eve Unity Scene Lowering",
"version": "0.3.18",
"version": "0.3.19",
"unity": "2021.3",
"description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.",
"dependencies": {

View file

@ -3,6 +3,7 @@ param(
[string] $AetheriaRoot = "E:\Projects\Aetheria",
[string] $CultLibRoot = "E:\Projects\CultLib",
[string] $EveUnityRoot = "E:\Projects\EveUnity",
[string] $YmirRoot = "E:\Projects\Ymir",
[string] $ClientProject = "ReleaseConsumerProject",
[int] $Port = 3076,
[string] $OutputDirectory = "artifacts\aetheria-daemon",
@ -31,7 +32,7 @@ $statePath = Join-Path $outputRoot "aetheria-witness-state.cc"
$daemonProject = Join-Path $AetheriaRoot "Aetheria.State.Daemon\Aetheria.State.Daemon.csproj"
$importProject = Join-Path $AetheriaRoot "Aetheria.State.Import\Aetheria.State.Import.csproj"
foreach ($required in @($UnityExe, (Join-Path $repoRoot $projectRoot), $daemonProject, $importProject)) {
foreach ($required in @($UnityExe, (Join-Path $repoRoot $projectRoot), $daemonProject, $importProject, (Join-Path $YmirRoot "src\Ymir.Core\Ymir.Core.csproj"))) {
if (-not (Test-Path -LiteralPath $required)) { throw "Required world witness path not found: $required" }
}
$canonicalWitnessTest = Join-Path $repoRoot "TestProject\Assets\Tests\PlayMode\GenericWorldCaptureTests.cs"
@ -76,7 +77,7 @@ foreach ($ephemeralPath in @(
$importArguments = @(
"run", "--project", $importProject,
"-p:CultLibRoot=$CultLibRoot", "-p:EveUnityRoot=$EveUnityRoot",
"-p:CultLibRoot=$CultLibRoot", "-p:EveUnityRoot=$EveUnityRoot", "-p:YmirRoot=$YmirRoot",
"--", $AetheriaRoot, $statePath
)
$importLogPath = Join-Path $outputRoot "aetheria-import.log"
@ -114,7 +115,7 @@ if (-not $SkipAssetBundleBuild) {
$daemonArguments = @(
"run", "--project", $daemonProject,
"-p:CultLibRoot=$CultLibRoot", "-p:EveUnityRoot=$EveUnityRoot",
"-p:CultLibRoot=$CultLibRoot", "-p:EveUnityRoot=$EveUnityRoot", "-p:YmirRoot=$YmirRoot",
"--",
"--root", $AetheriaRoot,
"--state", $statePath,
@ -135,13 +136,13 @@ Write-Host "Poll: Select-String -Path '$daemonLogPath' -Pattern 'Aetheria client
try {
$ready = $false
for ($attempt = 0; $attempt -lt 60; $attempt++) {
for ($attempt = 0; $attempt -lt 240; $attempt++) {
if ($daemon.HasExited) { throw "Aetheria daemon exited before publishing CultMesh. See $daemonLogPath" }
if ((Test-Path $daemonLogPath) -and
(Select-String -Path $daemonLogPath -Pattern "Aetheria client CultMesh endpoint: rudp://127.0.0.1:$Port" -Quiet)) { $ready = $true; break }
Start-Sleep -Milliseconds 500
}
if (-not $ready) { throw "Aetheria daemon did not open CultMesh port $Port. See $daemonLogPath" }
if (-not $ready) { throw "Aetheria daemon did not open CultMesh port $Port within 120 seconds. See $daemonLogPath" }
$env:EVEUNITY_RENDEZVOUS_ENDPOINT = "rudp://127.0.0.1:$Port"
$env:EVEUNITY_PROVIDER_ID = "aetheria"
@ -177,6 +178,10 @@ try {
}
}
$facts = Get-Content -LiteralPath $factsPath -Raw | ConvertFrom-Json
if (-not $facts.combatPresentation -or [string]::IsNullOrWhiteSpace($facts.shotId) -or
[double]$facts.lockProgress -le 0.99) {
throw "Live witness did not observe daemon-owned selection, completed lock, and a resolved shot."
}
$releaseLock = Get-Content -LiteralPath (Join-Path $repoRoot "ReleaseConsumerProject\Packages\packages-lock.json") -Raw | ConvertFrom-Json
$releasedPackageClient = $projectRoot -eq "ReleaseConsumerProject"
$sceneLock = $releaseLock.dependencies.'org.gamecult.eve.unity-scene'
@ -265,7 +270,7 @@ try {
sizeBytes = $resultsArtifact.Length
}
)
authority = "released-generic-runtime-observes-provider-advertisement-assets-command-receipts-and-republished-surface-versions"
authority = "released-generic-runtime-observes-provider-assets-authoritative-combat-receipts-and-camera-channel-separation"
}
$witness | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $witnessPath -Encoding UTF8
$stateWitnessPath = Join-Path $outputRoot "runtime-witness.$observedCacheState.json"

View file

@ -0,0 +1,121 @@
param(
[string] $UnityExe = "C:\Program Files\Unity\Hub\Editor\6000.4.2f1\Editor\Unity.exe",
[string] $AetheriaRoot = "E:\Projects\Aetheria",
[string] $CultLibRoot = "E:\Projects\CultLib-codex-cultmesh-reliability",
[string] $EveUnityRoot = "E:\Projects\EveUnity",
[string] $YmirRoot = "E:\Projects\Ymir",
[int] $Port = 3076,
[string] $OutputDirectory = "artifacts\interactive",
[switch] $SkipBuild
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
$project = Join-Path $root "ReleaseConsumerProject"
$outputRoot = if ([IO.Path]::IsPathRooted($OutputDirectory)) { $OutputDirectory } else { Join-Path $root $OutputDirectory }
$buildLog = Join-Path $outputRoot "build.log"
$bundleBuildLog = Join-Path $outputRoot "asset-bundle-build.log"
$daemonLog = Join-Path $outputRoot "daemon.log"
$importLog = Join-Path $outputRoot "import.log"
$importErrorLog = Join-Path $outputRoot "import.error.log"
$state = Join-Path $outputRoot "aetheria.cc"
$replica = Join-Path $outputRoot "eve-unity-replica.cc"
$assetCache = Join-Path $outputRoot "asset-cache"
$exe = Join-Path $project "Build\Windows\EveUnity.exe"
$importProject = Join-Path $AetheriaRoot "Aetheria.State.Import\Aetheria.State.Import.csproj"
$daemonProject = Join-Path $AetheriaRoot "Aetheria.State.Daemon\Aetheria.State.Daemon.csproj"
foreach ($required in @($UnityExe, $project, $importProject, $daemonProject, (Join-Path $YmirRoot "src\Ymir.Core\Ymir.Core.csproj"))) {
if (-not (Test-Path -LiteralPath $required)) { throw "Required interactive witness path not found: $required" }
}
if (Get-NetUDPEndpoint -LocalPort $Port -ErrorAction SilentlyContinue) {
throw "UDP port $Port is already occupied."
}
New-Item -ItemType Directory -Force $outputRoot, $assetCache | Out-Null
foreach ($ephemeralPath in @($state, "$state.records", "$state.cultmesh", $replica, "$replica.records", "$replica.cultmesh")) {
$resolved = [IO.Path]::GetFullPath($ephemeralPath)
if (-not $resolved.StartsWith([IO.Path]::GetFullPath($outputRoot), [StringComparison]::OrdinalIgnoreCase)) {
throw "Interactive cleanup escaped its artifact directory: $resolved"
}
if (Test-Path -LiteralPath $resolved) { Remove-Item -LiteralPath $resolved -Recurse -Force }
}
if (-not $SkipBuild -or -not (Test-Path $exe)) {
$build = Start-Process $UnityExe -ArgumentList @("-batchmode", "-quit", "-projectPath", $project, "-executeMethod", "GenericEveUnityBuild.BuildWindows", "-logFile", $buildLog) -PassThru -WindowStyle Hidden
Write-Host "Build PID: $($build.Id)"
Write-Host "Build log: $buildLog"
if (-not $build.WaitForExit(300000)) { Stop-Process $build.Id -Force; throw "EveUnity build timed out." }
if ($build.ExitCode -ne 0 -or -not (Test-Path $exe) -or (Select-String $buildLog -Pattern "Scripts have compiler errors|Build completed with a result of 'Failed'" -Quiet)) { Get-Content $buildLog -Tail 120; throw "EveUnity build failed." }
}
if (-not $SkipBuild) {
$bundleBuilder = Start-Process $UnityExe -ArgumentList @(
"-batchmode", "-quit", "-projectPath", ".",
"-executeMethod", "Aetheria.Editor.EveAssetBundleBuilder.BuildWindows",
"-logFile", $bundleBuildLog
) -WorkingDirectory $AetheriaRoot -PassThru -WindowStyle Hidden
Write-Host "AssetBundle build PID: $($bundleBuilder.Id)"
Write-Host "AssetBundle build log: $bundleBuildLog"
if (-not $bundleBuilder.WaitForExit(240000)) { Stop-Process $bundleBuilder.Id -Force; throw "Aetheria AssetBundle build timed out." }
if ($bundleBuilder.ExitCode -ne 0) { Get-Content $bundleBuildLog -Tail 120; throw "Aetheria AssetBundle build failed." }
}
$importArguments = @(
"run", "--project", $importProject,
"-p:CultLibRoot=$CultLibRoot", "-p:EveUnityRoot=$EveUnityRoot", "-p:YmirRoot=$YmirRoot",
"--", $AetheriaRoot, $state
)
$previousErrorActionPreference = $ErrorActionPreference
try {
$ErrorActionPreference = "Continue"
& dotnet @importArguments 1> $importLog 2> $importErrorLog
$importExitCode = $LASTEXITCODE
}
finally {
$ErrorActionPreference = $previousErrorActionPreference
}
if ($importExitCode -ne 0) { throw "Aetheria state import failed. See $importErrorLog" }
$daemon = Start-Process dotnet -ArgumentList @(
"run", "--project", $daemonProject,
"-p:CultLibRoot=$CultLibRoot", "-p:EveUnityRoot=$EveUnityRoot", "-p:YmirRoot=$YmirRoot",
"--",
"--root", $AetheriaRoot,
"--state", $state,
"--client-cultmesh-host", "127.0.0.1",
"--client-cultmesh-advertise-host", "127.0.0.1",
"--client-cultmesh-port", $Port,
"--tick-interval-ms", 250,
"--fixed-delta-ms", 20,
"--no-odin-announcements"
) -PassThru -WindowStyle Hidden -RedirectStandardOutput $daemonLog -RedirectStandardError "$daemonLog.error"
Write-Host "Daemon PID: $($daemon.Id)"
Write-Host "Daemon log: $daemonLog"
try {
$ready = $false
for ($i=0; $i -lt 240; $i++) {
if ($daemon.HasExited) { throw "Aetheria daemon exited. See $daemonLog" }
if ((Test-Path $daemonLog) -and
(Select-String $daemonLog -Pattern "Aetheria client CultMesh endpoint: rudp://127.0.0.1:$Port" -Quiet)) {
$ready = $true
break
}
Start-Sleep -Milliseconds 500
}
if (-not $ready) { throw "Aetheria daemon did not open CultMesh port $Port within 120 seconds. See $daemonLog" }
$env:EVEUNITY_RENDEZVOUS_ENDPOINT = "rudp://127.0.0.1:$Port"
$env:EVEUNITY_PROVIDER_ID = "aetheria"
$env:EVEUNITY_REPLICA_PATH = $replica
$env:EVEUNITY_ASSET_CACHE_PATH = $assetCache
Remove-Item Env:EVEUNITY_SURFACE_ID -ErrorAction SilentlyContinue
$client = Start-Process $exe -ArgumentList "-force-d3d11" -PassThru
Write-Host "Client PID: $($client.Id)"
Write-Host "Asset cache: $assetCache"
Write-Host "Close the EveUnity window when finished."
$client.WaitForExit()
}
finally {
if ($null -ne $daemon -and -not $daemon.HasExited) { Stop-Process $daemon.Id -Force }
Remove-Item Env:EVEUNITY_RENDEZVOUS_ENDPOINT, Env:EVEUNITY_PROVIDER_ENDPOINT, Env:EVEUNITY_PROVIDER_ID, Env:EVEUNITY_SURFACE_ID, Env:EVEUNITY_REPLICA_PATH, Env:EVEUNITY_ASSET_CACHE_PATH -ErrorAction SilentlyContinue
}