using System; using System.Collections.Generic; using Unity.Burst; using Unity.Collections; using Unity.Entities; using Unity.Jobs; using Unity.Mathematics; using Unity.Transforms; using UnityEngine; namespace jeanf.scenemanagement { /// /// Assigns zones to GameObjects by testing their pivot against the World-SubScene volume /// entities — the same volumes and the same test the player uses. /// Replaces the PhysX boundary-box approach (uvs ListObjectsWithinBoundary). /// /// Reads the ECS world through an EntityQuery (SeatDataBridge pattern); creates no entities. /// Objects register a Transform + callback; evaluation is event-driven (registration, moves, /// MarkDirty, volume streaming), Burst-parallel, and change-only on dispatch. Assignment and /// proximity visibility are computed as independent results — a distance check can never /// affect a zone write. /// /// Drop one on a persistent GameObject; it's a singleton. Ships in Shadow mode: assignments /// are computed and observable (parity recorders, inspector) but no callbacks fire. /// public class ObjectZoneTrackingBridge : MonoBehaviour { private const string LogPrefix = "[SceneManagement]"; public enum BridgeMode { Shadow, Active } public enum MatchKind : byte { None = 0, Exact = 1, Soft = 2, Fallback = 3, Lifted = 4 } public static ObjectZoneTrackingBridge Instance { get; private set; } [SerializeField] private bool isDebug = false; [SerializeField] private BridgeMode mode = BridgeMode.Shadow; [Tooltip("Seconds between dynamic-object position checks (hot tier: objects in the player's current zone or unassigned).")] [SerializeField] private float checkInterval = 0.2f; [Tooltip("Cold-tier dynamics (in another zone than the player) are checked every Nth interval.")] [SerializeField] private int coldTierMultiplier = 8; [Tooltip("Minimum movement before a dynamic object is re-tested.")] [SerializeField] private float minPositionChange = 0.1f; [Header("Coverage failsafe chain:")] [Tooltip("Epsilon pass: volume extents expanded by this many meters. Matches are logged as soft assignments.")] [SerializeField] private float coverageTolerance = 0.25f; [Tooltip("Nearest-candidate pass: max distance to the closest volume for a fallback assignment. <= 0 disables.")] [SerializeField] private float maxFallbackDistance = 2f; [Tooltip("Lifted pass: max meters an object's pivot may sit above a volume's top (X/Z footprint inside) and still be assigned to it. Covers logic objects lifted toward the ceiling to dodge the old physics-based detection. <= 0 disables.")] [SerializeField] private float maxLiftAbove = 4f; [Header("Zone-scoped proximity visibility:")] [Tooltip("Objects assigned to these zones get proximity visibility toggled by player distance (ProximityVisibilityChanged). Replaces the old per-boundary checkForProximity + setCustomList config (e.g. the RueDesCapucines street zone, threshold 3).")] [SerializeField] private List proximityVisibilityZones = new List(); [Serializable] public struct ProximityZoneConfig { public Zone zone; [Range(0.1f, 10f)] public float threshold; } /// Fired on real assignment changes, Active mode only: (object, oldZone, newZone). public static event Action ZoneChanged; /// Fired in every mode whenever an assignment is computed (changed or not) — parity/T3 hook. public static event Action AssignmentComputed; /// Zone-scoped proximity visibility (the CustomVisibility replacement), Active mode only. public static event Action ProximityVisibilityChanged; public BridgeMode Mode { get => mode; set => mode = value; } // Read-only views of the failsafe-chain and proximity config, for the editor // validation tools (EditorZoneResolver mirrors the chain with these values). public float CoverageTolerance => coverageTolerance; public float MaxLiftAboveVolume => maxLiftAbove; public float MaxFallbackDist => maxFallbackDistance; public IReadOnlyList ProximityZones => proximityVisibilityZones; private class Entry { public Transform Tf; public GameObject Go; // cached so a destroyed Transform can still be removed from zone lists public Action OnZoneChanged; public Action OnProximityChanged; public bool IsDynamic; public float ProximitySqr; public bool ProximityVisible; public Vector3 LastCheckedPos; public float LastCheckTime; public string ZoneId; // empty = unassigned (pending) public Zone Zone; public MatchKind Kind; public bool WarnedNoMatch; } private struct PendingRegistration { public Transform Tf; public Action OnZoneChanged; public bool IsDynamic; public Action OnProximityChanged; public float ProximityThreshold; } private static readonly List PreRegistrations = new List(64); private readonly List _entries = new List(256); private readonly Dictionary _byTransform = new Dictionary(256); private readonly List _dirty = new List(64); private readonly Dictionary> _objectsByZone = new Dictionary>(32); private static readonly List EmptyZoneList = new List(); // Candidate volumes (current region, unlocked zones), rebuilt on region/streaming change. private NativeArray _volL2W; private NativeArray _volScale; private string[] _volZoneIds = Array.Empty(); private Zone[] _volZones = Array.Empty(); private bool _candidatesValid; private readonly Dictionary _zoneToRegion = new Dictionary(64); private readonly Dictionary _proximityZoneSqr = new Dictionary(8); private readonly HashSet _candidateZoneIds = new HashSet(); private EntityManager _em; private EntityQuery _volumeQuery; private bool _worldReady; private int _lastVolumeCount = -1; private string _currentRegionId = ""; private string _currentZoneId = ""; private int _dynamicCursor; private float _sliceAccumulator; private float _lastProximityPass; private Transform _player; #region Registration API public static void Register(Transform transform, Action onZoneChanged, bool isDynamic = false, Action onProximityChanged = null, float proximityThreshold = 0f) { if (transform == null) return; if (Instance != null) { Instance.DoRegister(transform, onZoneChanged, isDynamic, onProximityChanged, proximityThreshold); return; } PreRegistrations.Add(new PendingRegistration { Tf = transform, OnZoneChanged = onZoneChanged, IsDynamic = isDynamic, OnProximityChanged = onProximityChanged, ProximityThreshold = proximityThreshold }); } public static void Unregister(Transform transform) { if (transform == null) return; if (Instance != null) { Instance.DoUnregister(transform); return; } for (var i = PreRegistrations.Count - 1; i >= 0; i--) if (PreRegistrations[i].Tf == transform) PreRegistrations.RemoveAt(i); } /// Force a re-test of one object (call after teleporting/carrying it). public static void MarkDirty(Transform transform) { if (Instance == null || transform == null) return; if (Instance._byTransform.TryGetValue(transform, out var e)) Instance.QueueDirty(e); } /// Re-test everything (compat hook for the old requestObjectPositionDetection channel). public static void RequestFullRetest() { if (Instance == null) return; Instance._candidatesValid = false; foreach (var e in Instance._entries) Instance.QueueDirty(e); } /// Current computed assignment (also valid in Shadow mode). False if unregistered/unassigned. public bool TryGetAssignment(Transform transform, out Zone zone, out MatchKind kind) { zone = null; kind = MatchKind.None; if (transform == null || !_byTransform.TryGetValue(transform, out var e) || e.Zone == null) return false; zone = e.Zone; kind = e.Kind; return true; } /// Objects currently assigned to a zone (replaces listOfObjectsPresentInRoom). public IReadOnlyList GetObjectsInZone(string zoneId) { return _objectsByZone.TryGetValue(zoneId, out var list) ? list : EmptyZoneList; } /// Zone ids that currently have at least one assigned object (debug/inspector). public IEnumerable ZoneIdsWithObjects { get { foreach (var kv in _objectsByZone) if (kv.Value.Count > 0) yield return kv.Key; } } /// Total registered objects (debug/inspector). public int TrackedObjectCount => _entries.Count; /// Objects that matched no volume yet — the coverage-gap alarm list. public IEnumerable PendingObjects { get { foreach (var e in _entries) if (string.IsNullOrEmpty(e.ZoneId) && e.Tf != null) yield return e.Tf.gameObject; } } #endregion #region Lifecycle private void Awake() { if (Instance != null && Instance != this) { Destroy(this); return; } Instance = this; } private void OnEnable() { WorldManager.PublishCurrentRegionId += OnRegionChanged; WorldManager.PublishCurrentZoneId += OnPlayerZoneChanged; RebuildProximityZoneLookup(); TryInitWorld(); DrainPreRegistrations(); } private void RebuildProximityZoneLookup() { _proximityZoneSqr.Clear(); foreach (var cfg in proximityVisibilityZones) { if (cfg.zone == null || cfg.threshold <= 0f) continue; _proximityZoneSqr[$"{cfg.zone.id}"] = cfg.threshold * cfg.threshold; } } #if UNITY_EDITOR private void OnValidate() { if (Application.isPlaying) RebuildProximityZoneLookup(); } #endif private void OnDisable() { WorldManager.PublishCurrentRegionId -= OnRegionChanged; WorldManager.PublishCurrentZoneId -= OnPlayerZoneChanged; } private void OnDestroy() { if (Instance == this) Instance = null; DisposeCandidates(); } private void DisposeCandidates() { if (_volL2W.IsCreated) _volL2W.Dispose(); if (_volScale.IsCreated) _volScale.Dispose(); } private void TryInitWorld() { var world = World.DefaultGameObjectInjectionWorld; if (world == null || !world.IsCreated) { _worldReady = false; return; } _em = world.EntityManager; _volumeQuery = _em.CreateEntityQuery(ComponentType.ReadOnly(), ComponentType.ReadOnly()); _worldReady = true; } private void DrainPreRegistrations() { if (PreRegistrations.Count == 0) return; foreach (var p in PreRegistrations) { if (p.Tf == null) continue; DoRegister(p.Tf, p.OnZoneChanged, p.IsDynamic, p.OnProximityChanged, p.ProximityThreshold); } PreRegistrations.Clear(); } private void DoRegister(Transform tf, Action onZoneChanged, bool isDynamic, Action onProximityChanged, float proximityThreshold) { if (_byTransform.TryGetValue(tf, out var existing)) { existing.OnZoneChanged = onZoneChanged; existing.OnProximityChanged = onProximityChanged; existing.IsDynamic = isDynamic; existing.ProximitySqr = proximityThreshold * proximityThreshold; QueueDirty(existing); return; } var e = new Entry { Tf = tf, Go = tf.gameObject, OnZoneChanged = onZoneChanged, OnProximityChanged = onProximityChanged, IsDynamic = isDynamic, ProximitySqr = proximityThreshold * proximityThreshold, LastCheckedPos = tf.position, ZoneId = "", }; _entries.Add(e); _byTransform.Add(tf, e); QueueDirty(e); } private void DoUnregister(Transform tf) { if (!_byTransform.TryGetValue(tf, out var e)) return; _byTransform.Remove(tf); _entries.Remove(e); _dirty.Remove(e); RemoveFromZoneList(e); } #endregion #region Events private void OnRegionChanged(string regionId) { _currentRegionId = regionId ?? ""; _candidatesValid = false; // Statics keep their (correct) last zone; only never-assigned objects and movers // can be affected by a region change. foreach (var e in _entries) if (string.IsNullOrEmpty(e.ZoneId) || e.IsDynamic) QueueDirty(e); } private void OnPlayerZoneChanged(string zoneId) { _currentZoneId = zoneId ?? ""; } private void QueueDirty(Entry e) { if (!_dirty.Contains(e)) _dirty.Add(e); } #endregion #region Update loop private void Update() { if (!_worldReady) { TryInitWorld(); if (!_worldReady) return; } if (_player == null && Camera.main != null) _player = Camera.main.transform; DetectVolumeStreamingChanges(); WalkDynamicObjects(); if (_dirty.Count > 0) { if (!_candidatesValid) RebuildCandidates(); if (_candidatesValid && _volZoneIds.Length > 0) EvaluateDirty(); else if (_volZoneIds.Length == 0) _dirty.Clear(); // no volumes streamed in yet; retried on streaming change } UpdateProximity(); } /// SubScene sections streaming in/out change the volume set → re-test pending + dynamics. private void DetectVolumeStreamingChanges() { var count = _volumeQuery.CalculateEntityCount(); if (count == _lastVolumeCount) return; _lastVolumeCount = count; _candidatesValid = false; foreach (var e in _entries) if (string.IsNullOrEmpty(e.ZoneId) || e.IsDynamic) QueueDirty(e); } /// /// Staggered cursor over dynamic entries: each frame visits a slice sized so the whole /// list is covered once per interval; a visited entry is only position-compared when its /// own tier cadence (hot = every interval, cold = every Nth) says it is due. /// private void WalkDynamicObjects() { var dynamicCount = 0; foreach (var e in _entries) if (e.IsDynamic) dynamicCount++; if (dynamicCount == 0) return; _sliceAccumulator += dynamicCount * (Time.deltaTime / Mathf.Max(0.01f, checkInterval)); var steps = (int)_sliceAccumulator; if (steps <= 0) return; _sliceAccumulator -= steps; steps = Mathf.Min(steps, dynamicCount); var now = Time.time; var visited = 0; for (var i = 0; i < _entries.Count && visited < steps; i++) { _dynamicCursor = (_dynamicCursor + 1) % _entries.Count; var e = _entries[_dynamicCursor]; if (!e.IsDynamic) continue; visited++; if (e.Tf == null) continue; var isHot = string.IsNullOrEmpty(e.ZoneId) || e.ZoneId == _currentZoneId; var due = now - e.LastCheckTime >= (isHot ? checkInterval : checkInterval * Mathf.Max(1, coldTierMultiplier)); if (!due) continue; e.LastCheckTime = now; var pos = e.Tf.position; if ((pos - e.LastCheckedPos).sqrMagnitude < minPositionChange * minPositionChange) continue; e.LastCheckedPos = pos; QueueDirty(e); } } #endregion #region Candidate volumes private void RebuildCandidates() { DisposeCandidates(); _volZoneIds = Array.Empty(); _volZones = Array.Empty(); var zoneDict = WorldManager.GetZoneDictionary(); if (zoneDict == null) return; RefreshZoneToRegionMap(); var l2ws = _volumeQuery.ToComponentDataArray(Allocator.Temp); var volumes = _volumeQuery.ToComponentDataArray(Allocator.Temp); var keptL2W = new List(l2ws.Length); var keptScale = new List(l2ws.Length); var keptZoneIds = new List(l2ws.Length); var keptZones = new List(l2ws.Length); for (var i = 0; i < volumes.Length; i++) { if (volumes[i].ZoneId.IsEmpty) continue; var zoneId = volumes[i].ZoneId.ToString(); if (!zoneDict.TryGetValue(zoneId, out var zone) || zone == null) continue; if (!zone.IsAccessible()) continue; // locked zones drop out // Region filter: keep volumes of the current region; with no region yet (startup), // keep everything unlocked — mirrors VolumeSystem's empty-zone behavior. if (!string.IsNullOrEmpty(_currentRegionId) && _zoneToRegion.TryGetValue(zoneId, out var regionId) && regionId != _currentRegionId) continue; keptL2W.Add(l2ws[i].Value); keptScale.Add(volumes[i].Scale); keptZoneIds.Add(zoneId); keptZones.Add(zone); } l2ws.Dispose(); volumes.Dispose(); _volL2W = new NativeArray(keptL2W.ToArray(), Allocator.Persistent); _volScale = new NativeArray(keptScale.ToArray(), Allocator.Persistent); _volZoneIds = keptZoneIds.ToArray(); _volZones = keptZones.ToArray(); _candidateZoneIds.Clear(); foreach (var id in keptZoneIds) _candidateZoneIds.Add(id); _candidatesValid = true; if (isDebug) Debug.Log($"{LogPrefix} ObjectZoneTrackingBridge: {_volZoneIds.Length} candidate volume(s) for region '{_currentRegionId}'.", this); } /// Zone→region map from the same precomputed buffer VolumeSystem uses. private void RefreshZoneToRegionMap() { if (_zoneToRegion.Count > 0) return; var q = _em.CreateEntityQuery(ComponentType.ReadOnly()); if (q.CalculateEntityCount() == 0) return; var buffer = _em.GetBuffer(q.GetSingletonEntity(), true); for (var i = 0; i < buffer.Length; i++) { var entry = buffer[i]; if (entry.isZoneRegionMapping && !entry.zoneId.IsEmpty && !entry.regionId.IsEmpty) _zoneToRegion[entry.zoneId.ToString()] = entry.regionId.ToString(); } } #endregion #region Evaluation [BurstCompile] private struct AssignZoneJob : IJobParallelFor { [ReadOnly] public NativeArray Positions; [ReadOnly] public NativeArray VolL2W; [ReadOnly] public NativeArray VolScale; public float Tolerance; public float MaxLiftAbove; public float MaxFallbackDistSq; public NativeArray ResultVolume; public NativeArray ResultKind; public void Execute(int index) { var p = Positions[index]; // 1) exact containment for (var v = 0; v < VolL2W.Length; v++) { if (!VolumeMath.ContainsPoint(VolL2W[v], VolScale[v], p)) continue; ResultVolume[index] = v; ResultKind[index] = (byte)MatchKind.Exact; return; } // 2) epsilon pass if (Tolerance > 0f) { for (var v = 0; v < VolL2W.Length; v++) { if (!VolumeMath.ContainsPoint(VolL2W[v], VolScale[v], p, Tolerance)) continue; ResultVolume[index] = v; ResultKind[index] = (byte)MatchKind.Soft; return; } } // 3) lifted pass: X/Z footprint inside a volume, pivot above its top // (ceiling-lifted logic objects). Smallest overshoot wins. if (MaxLiftAbove > 0f) { var bestLift = -1; var bestOvershoot = MaxLiftAbove; for (var v = 0; v < VolL2W.Length; v++) { var overshoot = VolumeMath.LiftAbove(VolL2W[v], VolScale[v], p); if (overshoot <= 0f || overshoot >= bestOvershoot) continue; bestOvershoot = overshoot; bestLift = v; } if (bestLift >= 0) { ResultVolume[index] = bestLift; ResultKind[index] = (byte)MatchKind.Lifted; return; } } // 4) nearest candidate within limit if (MaxFallbackDistSq > 0f) { var best = -1; var bestDistSq = MaxFallbackDistSq; for (var v = 0; v < VolL2W.Length; v++) { var distSq = VolumeMath.DistanceSq(VolL2W[v], VolScale[v], p); if (distSq >= bestDistSq) continue; bestDistSq = distSq; best = v; } if (best >= 0) { ResultVolume[index] = best; ResultKind[index] = (byte)MatchKind.Fallback; return; } } // 5) pending + alarm (handled on dispatch) ResultVolume[index] = -1; ResultKind[index] = (byte)MatchKind.None; } } private void EvaluateDirty() { // Compact out dead transforms first. for (var i = _dirty.Count - 1; i >= 0; i--) if (_dirty[i].Tf == null) { DoUnregister(_dirty[i].Tf); _dirty.RemoveAt(i); } if (_dirty.Count == 0) return; var count = _dirty.Count; var positions = new NativeArray(count, Allocator.TempJob); var resultVolume = new NativeArray(count, Allocator.TempJob); var resultKind = new NativeArray(count, Allocator.TempJob); for (var i = 0; i < count; i++) positions[i] = _dirty[i].Tf.position; var job = new AssignZoneJob { Positions = positions, VolL2W = _volL2W, VolScale = _volScale, Tolerance = coverageTolerance, MaxLiftAbove = maxLiftAbove, MaxFallbackDistSq = maxFallbackDistance > 0f ? maxFallbackDistance * maxFallbackDistance : 0f, ResultVolume = resultVolume, ResultKind = resultKind, }; job.Schedule(count, 8).Complete(); for (var i = 0; i < count; i++) DispatchResult(_dirty[i], resultVolume[i], (MatchKind)resultKind[i]); _dirty.Clear(); positions.Dispose(); resultVolume.Dispose(); resultKind.Dispose(); } private void DispatchResult(Entry e, int volumeIndex, MatchKind kind) { if (volumeIndex < 0) { // Failsafe step 4: stays pending (retried on every streaming/region change), never silent. if (!e.WarnedNoMatch) { e.WarnedNoMatch = true; Debug.LogWarning($"{LogPrefix} ObjectZoneTrackingBridge: '{e.Tf.name}' matched no volume " + $"(coverage gap at {e.Tf.position}) — object is missing from zone lists until coverage is fixed.", e.Tf); } AssignmentComputed?.Invoke(e.Go, "", MatchKind.None); return; } var zoneId = _volZoneIds[volumeIndex]; var zone = _volZones[volumeIndex]; e.WarnedNoMatch = false; e.Kind = kind; if (kind == MatchKind.Soft) Debug.Log($"{LogPrefix} ObjectZoneTrackingBridge: soft assignment '{e.Tf.name}' → '{zoneId}' " + $"(within {coverageTolerance}m tolerance) — consider enlarging the volume.", e.Tf); else if (kind == MatchKind.Lifted) Debug.LogWarning($"{LogPrefix} ObjectZoneTrackingBridge: lifted assignment '{e.Tf.name}' → '{zoneId}' " + $"(pivot above the volume top, within {maxLiftAbove}m) — lower the object or raise the volume.", e.Tf); else if (kind == MatchKind.Fallback) Debug.LogWarning($"{LogPrefix} ObjectZoneTrackingBridge: fallback assignment '{e.Tf.name}' → '{zoneId}' " + $"(nearest volume within {maxFallbackDistance}m) — coverage gap, fix the volume.", e.Tf); AssignmentComputed?.Invoke(e.Go, zoneId, kind); if (e.ZoneId == zoneId) return; // change-only dispatch var oldZone = e.Zone; RemoveFromZoneList(e); e.ZoneId = zoneId; e.Zone = zone; AddToZoneList(e); if (isDebug) Debug.Log($"{LogPrefix} ObjectZoneTrackingBridge: '{e.Tf.name}' → zone '{zoneId}' ({kind}).", e.Tf); if (mode != BridgeMode.Active) return; // Shadow: computed + observable, but writes nothing e.OnZoneChanged?.Invoke(zone); ZoneChanged?.Invoke(e.Go, oldZone, zone); } private void AddToZoneList(Entry e) { if (string.IsNullOrEmpty(e.ZoneId)) return; if (!_objectsByZone.TryGetValue(e.ZoneId, out var list)) { list = new List(16); _objectsByZone.Add(e.ZoneId, list); } if (!list.Contains(e.Go)) list.Add(e.Go); } private void RemoveFromZoneList(Entry e) { if (string.IsNullOrEmpty(e.ZoneId)) return; if (_objectsByZone.TryGetValue(e.ZoneId, out var list)) list.Remove(e.Go); } /// /// Proximity visibility — independent of assignment by construction: reads player /// distance only, writes visibility only. Two config paths: per-registration callback /// (explicit threshold) and zone-scoped config (proximityVisibilityZones → static /// ProximityVisibilityChanged, the CustomVisibility replacement). Gated on the entry's /// zone being in the current candidate set — same scope as the old /// isPlayerInSameRegion gate. /// private void UpdateProximity() { if (_player == null) return; if (Time.time - _lastProximityPass < checkInterval) return; _lastProximityPass = Time.time; var playerPos = _player.position; foreach (var e in _entries) { if (e.Tf == null || string.IsNullOrEmpty(e.ZoneId)) continue; var thresholdSqr = 0f; var viaZoneConfig = false; if (e.OnProximityChanged != null && e.ProximitySqr > 0f) { thresholdSqr = e.ProximitySqr; } else if (_proximityZoneSqr.TryGetValue(e.ZoneId, out var zoneSqr)) { thresholdSqr = zoneSqr; viaZoneConfig = true; } if (thresholdSqr <= 0f) continue; var inRegion = _candidateZoneIds.Contains(e.ZoneId); // Horizontal distance only: "near this object" must not depend on how high the // logic object's pivot sits (ceiling-lifted objects added 2-3m of dead offset). var delta = e.Tf.position - playerPos; delta.y = 0f; var visible = inRegion && delta.sqrMagnitude <= thresholdSqr; if (visible == e.ProximityVisible) continue; e.ProximityVisible = visible; if (mode != BridgeMode.Active) continue; if (viaZoneConfig) ProximityVisibilityChanged?.Invoke(e.Go, visible); else e.OnProximityChanged?.Invoke(visible); } } #endregion } }