diff --git a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs index 19194cc..a3d60a7 100644 --- a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs +++ b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs @@ -190,6 +190,9 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests string combatActionId = ""; string collectedPickupId = ""; bool pickupCollected = false; + bool docked = false; + bool undocked = false; + string stationObservedHostileId = ""; long initialVersion = 0; float movementDistance = 0f; var commandReceipts = new Dictionary(StringComparer.Ordinal); @@ -251,6 +254,11 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests seededPickup.GetComponentsInChildren(includeInactive: true).Length, Is.GreaterThan(1), "The generic client did not instantiate the provider-authored pickup asset."); + var dockAction = provider.CurrentInputCapability.Actions + .Single(candidate => string.Equals(candidate.ActionId, "pilot.dock", StringComparison.Ordinal)); + var undockAction = provider.CurrentInputCapability.Actions + .Single(candidate => string.Equals(candidate.ActionId, "pilot.undock", StringComparison.Ordinal)); + var dock = runtime.SubmitActionIntent(playerId, dockAction.ActionId); var realtimeAction = provider.CurrentInputCapability?.Actions .FirstOrDefault(action => string.Equals(action.ActionId, "simulation.rate.realtime", StringComparison.Ordinal)); Assert.That(realtimeAction, Is.Not.Null, "Terminus did not advertise its user-controlled simulation clock."); @@ -268,6 +276,49 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests marker.GetComponentsInChildren(includeInactive: true).Length, Is.GreaterThan(1), "The generic client substituted its primitive fallback instead of the provider-authored AssetBundle prefab."); + var dockDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < dockDeadline && + (!commandReceipts.TryGetValue(dock.CommandId, out var observedDock) || + runtime.ActiveVersion < observedDock.SourceVersion || + !string.Equals(runtime.ActiveWorld.PresentationMode, "docked", StringComparison.Ordinal))) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + Assert.That(commandReceipts.TryGetValue(dock.CommandId, out var dockReceipt), Is.True); + Assert.That(dockReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), dockReceipt.Message); + Assert.That(runtime.ActiveWorld.PresentationMode, Is.EqualTo("docked")); + Assert.That(runtime.ActiveWorld.CameraTargetEntityId, Is.Not.Empty.And.Not.EqualTo(playerId)); + Assert.That(FindMarker(root, runtime.ActiveWorld.CameraTargetEntityId), Is.Not.Null, + "The daemon did not include the authoritative dock parent in the docked SoA view."); + stationObservedHostileId = provider.CurrentEntityView.Identities + .FirstOrDefault(identity => string.Equals(identity.Faction, "raider", StringComparison.Ordinal))?.EntityId ?? ""; + Assert.That(stationObservedHostileId, Is.Not.Empty, + "The docked pilot did not inherit the station's sensor-derived hostile contacts."); + Assert.That(runtime.ActiveWorld.SubjectVisible, Is.False); + Assert.That(runtime.ActiveWorld.MovementEnabled, Is.False); + Assert.That(FindMarker(root, playerId, includeInactive: true).gameObject.activeSelf, Is.False); + docked = true; + + var undock = runtime.SubmitActionIntent(playerId, undockAction.ActionId); + var undockDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < undockDeadline && + (!commandReceipts.TryGetValue(undock.CommandId, out var observedUndock) || + runtime.ActiveVersion < observedUndock.SourceVersion || + !string.Equals(runtime.ActiveWorld.PresentationMode, "world", StringComparison.Ordinal))) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + Assert.That(commandReceipts.TryGetValue(undock.CommandId, out var undockReceipt), Is.True); + Assert.That(undockReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), undockReceipt.Message); + Assert.That(runtime.ActiveWorld.PresentationMode, Is.EqualTo("world")); + Assert.That(runtime.ActiveWorld.CameraTargetEntityId, Is.EqualTo(playerId)); + Assert.That(runtime.ActiveWorld.SubjectVisible, Is.True); + Assert.That(runtime.ActiveWorld.MovementEnabled, Is.True); + Assert.That(FindMarker(root, playerId).gameObject.activeSelf, Is.True); + undocked = true; + var initialPosition = marker.transform.position; initialVersion = runtime.ActiveVersion; var request = runtime.SubmitMoveVectorIntent(playerId, 1f, 0f, 1f); @@ -337,17 +388,18 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests var visibleMarkers = root.GetComponentsInChildren(); var hostileTargets = visibleMarkers .Where(candidate => candidate.Selectable && !candidate.Controllable && + string.Equals(candidate.EntityKind, "ship", StringComparison.OrdinalIgnoreCase) && !string.Equals(candidate.Faction, marker.Faction, StringComparison.Ordinal)) .OrderBy(candidate => Vector3.Distance(candidate.transform.position, marker.transform.position)) .ToArray(); - Assert.That( - hostileTargets, - Is.Not.Empty, - "No selectable hostile contact was lowered. Visible entities: " + + var targetEntityId = hostileTargets.Length > 0 + ? hostileTargets[0].EntityId + : stationObservedHostileId; + Assert.That(targetEntityId, Is.Not.Empty, + "No hostile contact was available from pilot or shared station sensors. Visible entities: " + string.Join(", ", visibleMarkers.Select(candidate => $"{candidate.EntityId}[kind={candidate.EntityKind},faction={candidate.Faction},selectable={candidate.Selectable}]"))); - var targetMarker = hostileTargets[0]; - var focus = runtime.SubmitTargetIntent(playerId, targetMarker.EntityId); + var focus = runtime.SubmitTargetIntent(playerId, targetEntityId); var focusVersion = runtime.ActiveVersion; var trajectoryRenderer = root.AddComponent(); runtime.ShotAvailable += shot => @@ -464,7 +516,9 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests combatActionId, combatShot, collectedPickupId, - pickupCollected); + pickupCollected, + docked, + undocked); } finally { @@ -490,7 +544,9 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests string combatActionId, EveUnityShotReceipt combatShot, string collectedPickupId, - bool pickupCollected) + bool pickupCollected, + bool docked, + bool undocked) { var path = Environment.GetEnvironmentVariable("EVEUNITY_WITNESS_FACTS_PATH"); if (string.IsNullOrWhiteSpace(path)) return; @@ -509,6 +565,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests shotHit = combatShot?.Hit ?? false, pickupVisible = !string.IsNullOrWhiteSpace(collectedPickupId), pickupCollected = pickupCollected, + docked = docked, + undocked = undocked, pickupId = collectedPickupId ?? "", initialVersion = initialVersion, finalVersion = finalVersion, @@ -535,6 +593,8 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests public bool shotHit; public bool pickupVisible; public bool pickupCollected; + public bool docked; + public bool undocked; public string pickupId; public long initialVersion; public long finalVersion; @@ -562,9 +622,12 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests } } - private static EveUnityPlayableWorldEntityMarker FindMarker(GameObject root, string entityId) + private static EveUnityPlayableWorldEntityMarker FindMarker( + GameObject root, + string entityId, + bool includeInactive = false) { - foreach (var marker in root.GetComponentsInChildren()) + foreach (var marker in root.GetComponentsInChildren(includeInactive)) { if (string.Equals(marker.EntityId, entityId, StringComparison.Ordinal)) return marker; diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs index 75a9696..e4cb824 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs @@ -21,6 +21,8 @@ namespace GameCult.Eve.UnityScene private readonly Transform _root; private readonly IEveUnityGameObjectAssetProvider _assetProvider; private readonly Dictionary _instances = new Dictionary(StringComparer.Ordinal); + private string _subjectEntityId = ""; + private bool _subjectVisible = true; public EveUnityGameObjectPlayableWorldSceneSink( Transform root, @@ -37,6 +39,9 @@ namespace GameCult.Eve.UnityScene if (world == null) throw new ArgumentNullException(nameof(world)); if (!string.IsNullOrWhiteSpace(world.WorldRootId)) _root.name = world.WorldRootId; + _subjectEntityId = world.PlayerEntityId; + _subjectVisible = world.SubjectVisible; + ApplySubjectVisibility(); } public void UpsertEntity(EveUnityPlayableWorldEntity entity, EveUnityPlayableWorldAssetBinding asset) @@ -64,6 +69,7 @@ namespace GameCult.Eve.UnityScene } ApplyEntity(instance, entity, asset); + ApplySubjectVisibility(instance, entity.EntityId); } public void RemoveEntity(string entityId) @@ -140,6 +146,19 @@ namespace GameCult.Eve.UnityScene else UnityEngine.Object.DestroyImmediate(instance); } + + private void ApplySubjectVisibility() + { + foreach (var pair in _instances) + ApplySubjectVisibility(pair.Value, pair.Key); + } + + private void ApplySubjectVisibility(GameObject instance, string entityId) + { + if (instance == null || !string.Equals(entityId, _subjectEntityId, StringComparison.Ordinal)) + return; + instance.SetActive(_subjectVisible); + } } public sealed class EveUnitySemanticEntityPresentation : MonoBehaviour diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs index 996c5bb..942e9f9 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs @@ -53,8 +53,8 @@ namespace GameCult.Eve.UnityScene } var camera = cameraTransform != null ? cameraTransform : transform; - var player = FindPlayerMarker(resolvedHost, activeWorld.PlayerEntityId); - if (player == null) + var targetMarker = FindEntityMarker(resolvedHost, activeWorld.CameraTargetEntityId); + if (targetMarker == null) return false; var cameraComponent = camera.GetComponent(); @@ -64,8 +64,8 @@ namespace GameCult.Eve.UnityScene { cameraComponent.cullingMask = cullingMask; } - var bounds = CalculateVisualBounds(player); - var target = bounds?.center ?? player.transform.position; + var bounds = CalculateVisualBounds(targetMarker); + var target = bounds?.center ?? targetMarker.transform.position; var radius = bounds?.extents.magnitude ?? 0f; var verticalFov = cameraComponent != null ? cameraComponent.fieldOfView : 60f; var framingDistance = radius > 0f @@ -84,9 +84,9 @@ namespace GameCult.Eve.UnityScene return true; } - private static Bounds? CalculateVisualBounds(EveUnityPlayableWorldEntityMarker player) + private static Bounds? CalculateVisualBounds(EveUnityPlayableWorldEntityMarker target) { - var renderers = player.GetComponentsInChildren(includeInactive: false); + var renderers = target.GetComponentsInChildren(includeInactive: false); Bounds? bounds = null; foreach (var visual in renderers) { @@ -121,17 +121,17 @@ namespace GameCult.Eve.UnityScene return host; } - private static EveUnityPlayableWorldEntityMarker? FindPlayerMarker( + private static EveUnityPlayableWorldEntityMarker? FindEntityMarker( EveUnityPlayableWorldClientHost host, - string playerEntityId) + string entityId) { - if (string.IsNullOrWhiteSpace(playerEntityId)) + if (string.IsNullOrWhiteSpace(entityId)) return null; - var markers = host.SceneRoot.GetComponentsInChildren(); + var markers = host.SceneRoot.GetComponentsInChildren(includeInactive: true); foreach (var marker in markers) { - if (marker != null && marker.EntityId == playerEntityId) + if (marker != null && marker.EntityId == entityId) return marker; } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs index 03850e1..2ba9bef 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs @@ -39,7 +39,7 @@ namespace GameCult.Eve.UnityScene DateTimeOffset? issuedAt = null) { var resolvedHost = ResolveHost(); - if (resolvedHost == null || resolvedHost.ActiveWorld == null) + if (resolvedHost == null || resolvedHost.ActiveWorld == null || !resolvedHost.ActiveWorld.MovementEnabled) return null; var movement = EveUnityPlayableWorldMoveVector.FromCameraRelativeInput( diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs index d5b5905..b0217c3 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs @@ -125,7 +125,11 @@ namespace GameCult.Eve.UnityScene worldRoot.GetProp("entityViewSchema"), worldRoot.GetProp("zoneRenderPointerId"), worldRoot.GetProp("zoneRenderSchema"), - worldRoot.GetProp("inputCapability")); + worldRoot.GetProp("inputCapability"), + worldRoot.GetProp("cameraTargetEntityId"), + ParseBoolean(worldRoot.GetProp("subjectVisible", "true")), + ParseBoolean(worldRoot.GetProp("movementEnabled", "true")), + worldRoot.GetProp("presentationMode", "world")); } private static EveUnityPlayableWorldEntity BuildPlayableEntity(EveSurfaceComponent component) @@ -326,7 +330,11 @@ namespace GameCult.Eve.UnityScene string entityViewSchema = "", string zoneRenderPointerId = "", string zoneRenderSchema = "", - string inputCapabilityPointerId = "") + string inputCapabilityPointerId = "", + string cameraTargetEntityId = "", + bool subjectVisible = true, + bool movementEnabled = true, + string presentationMode = "world") { WorldRootId = worldRootId ?? ""; StatePointerId = statePointerId ?? ""; @@ -345,6 +353,12 @@ namespace GameCult.Eve.UnityScene ZoneRenderPointerId = zoneRenderPointerId ?? ""; ZoneRenderSchema = zoneRenderSchema ?? ""; InputCapabilityPointerId = inputCapabilityPointerId ?? ""; + CameraTargetEntityId = string.IsNullOrWhiteSpace(cameraTargetEntityId) + ? PlayerEntityId + : cameraTargetEntityId; + SubjectVisible = subjectVisible; + MovementEnabled = movementEnabled; + PresentationMode = presentationMode ?? ""; } public string WorldRootId { get; } @@ -371,6 +385,14 @@ namespace GameCult.Eve.UnityScene public string PlayerEntityId { get; } + public string CameraTargetEntityId { get; } + + public bool SubjectVisible { get; } + + public bool MovementEnabled { get; } + + public string PresentationMode { get; } + public string MovementCommand { get; } public string FocusCommand { get; } diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs index c0cae75..5c3557e 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs @@ -117,6 +117,10 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(projection.PlayableWorld.InputProfile, Is.EqualTo("arpg-third-person")); Assert.That(projection.PlayableWorld.CameraRig, Is.EqualTo("third-person-orbit")); Assert.That(projection.PlayableWorld.PlayerEntityId, Is.EqualTo("player-vanguard")); + Assert.That(projection.PlayableWorld.CameraTargetEntityId, Is.EqualTo("player-vanguard")); + Assert.That(projection.PlayableWorld.SubjectVisible, Is.True); + Assert.That(projection.PlayableWorld.MovementEnabled, Is.True); + Assert.That(projection.PlayableWorld.PresentationMode, Is.EqualTo("world")); Assert.That(projection.PlayableWorld.MovementCommand, Is.EqualTo("aetheria.daemon.move_intent")); Assert.That(projection.PlayableWorld.FocusCommand, Is.EqualTo("aetheria.daemon.focus")); Assert.That(projection.PlayableWorld.TargetCommand, Is.EqualTo("aetheria.daemon.target")); @@ -1419,6 +1423,10 @@ namespace GameCult.Eve.UnityScene.Tests ["inputProfile"] = "arpg-third-person", ["cameraRig"] = "third-person-orbit", ["playerEntityId"] = "player-vanguard", + ["cameraTargetEntityId"] = "player-vanguard", + ["subjectVisible"] = "true", + ["movementEnabled"] = "true", + ["presentationMode"] = "world", ["movementCommand"] = "aetheria.daemon.move_intent", ["focusCommand"] = "aetheria.daemon.focus", ["targetCommand"] = "aetheria.daemon.target",