using System.Collections.Generic;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
using UnityEngine;
namespace jeanf.scenemanagement
{
///
/// Bridges props baked by back into the GameObject world.
/// Baking strips components and this project has no ECS physics, so a prop
/// inside a SubScene is rendered geometry the player walks straight through. This queries the
/// baked shapes and spawns plain PhysX colliders over the props near the player — which is what
/// the CharacterController (and every raycast) actually collides with.
///
/// Props are static, so this is a light reconcile — place once, drop when the prop leaves range
/// or its section streams out — rather than a per-frame moving-collider pool. Mirrors
/// SeatDataBridge and the door system's data bridge + collider pool.
///
/// Drop one on an always-loaded GameObject; it's a singleton. Without it, baked props do not
/// collide at all.
///
public class StaticColliderBridge : MonoBehaviour
{
private const string LogPrefix = "[SceneManagement]";
public static StaticColliderBridge Instance { get; private set; }
[SerializeField] private bool isDebug = false;
[Tooltip("Seconds between re-scans of baked props (handles SubScene streaming) and proxy reconciles. Props never move, so this can be slow.")]
[SerializeField] private float refreshInterval = 0.25f;
[Tooltip("Only props within this distance of the camera get live colliders. <= 0 means no limit.")]
[SerializeField] private float cullingDistance = 30f;
[Tooltip("Hard cap on simultaneously spawned props. The nearest ones win. <= 0 means no cap. Raise it if props stop blocking on a dense floor.")]
[SerializeField] private int maxProxies = 128;
private struct PropInfo
{
public Vector3 Position;
public Quaternion Rotation;
public Vector3 LossyScale;
}
private EntityManager _em;
private EntityQuery _propQuery;
private bool _worldReady;
private float _timer;
private int _lastLoggedCount = -1;
private bool _capWarned;
private Transform _container;
private Transform _camera;
private readonly Dictionary _props = new Dictionary(128);
private readonly Dictionary _proxies = new Dictionary(128);
private readonly List _candidates = new List(128);
private readonly List _candidateDistances = new List(128);
private readonly List _selected = new List(128);
private readonly List _toRemove = new List(32);
private readonly HashSet _seen = new HashSet();
private void Awake()
{
if (Instance != null && Instance != this) { Destroy(this); return; }
Instance = this;
// A root container at identity scale, so a proxy's localScale == the prop's lossy scale.
_container = new GameObject("StaticColliderProxy_Pool").transform;
}
private void OnDestroy()
{
if (Instance == this) Instance = null;
if (_container != null) Destroy(_container.gameObject);
}
private void OnEnable()
{
_timer = float.MaxValue; // scan on the first Update
TryInitWorld();
}
private void TryInitWorld()
{
var world = World.DefaultGameObjectInjectionWorld;
if (world == null || !world.IsCreated) { _worldReady = false; return; }
_em = world.EntityManager;
_propQuery = _em.CreateEntityQuery(ComponentType.ReadOnly());
_worldReady = true;
}
private void Update()
{
if (!_worldReady)
{
TryInitWorld();
if (!_worldReady) return;
}
if (_camera == null && Camera.main != null) _camera = Camera.main.transform;
_timer += Time.deltaTime;
if (_timer < refreshInterval) return;
_timer = 0f;
Refresh();
Reconcile();
}
// --- Query baked props -------------------------------------------------
private void Refresh()
{
_props.Clear();
var entities = _propQuery.ToEntityArray(Allocator.Temp);
for (var i = 0; i < entities.Length; i++)
{
var e = entities[i];
if (!_em.HasComponent(e)) continue;
var m = _em.GetComponentData(e).Value;
_props[e] = new PropInfo
{
Position = m.c3.xyz,
Rotation = ExtractRotation(m),
LossyScale = new Vector3(math.length(m.c0.xyz), math.length(m.c1.xyz), math.length(m.c2.xyz)),
};
}
entities.Dispose();
if (isDebug && _props.Count != _lastLoggedCount)
{
_lastLoggedCount = _props.Count;
Debug.Log($"{LogPrefix} StaticColliderBridge: {_props.Count} baked prop(s) available.", this);
}
}
private static Quaternion ExtractRotation(float4x4 m)
{
var axisX = m.c0.xyz;
var axisY = m.c1.xyz;
var axisZ = m.c2.xyz;
var scaleX = math.length(axisX);
var scaleY = math.length(axisY);
var scaleZ = math.length(axisZ);
axisX = scaleX > 1e-8f ? axisX / scaleX : new float3(1f, 0f, 0f);
axisY = scaleY > 1e-8f ? axisY / scaleY : new float3(0f, 1f, 0f);
axisZ = scaleZ > 1e-8f ? axisZ / scaleZ : new float3(0f, 0f, 1f);
return new quaternion(new float3x3(axisX, axisY, axisZ));
}
// --- Spawn / drop GameObject collider proxies --------------------------
private void Reconcile()
{
var haveCam = _camera != null;
var camPos = haveCam ? _camera.position : Vector3.zero;
var cull = cullingDistance > 0f && haveCam;
var cullSqr = cullingDistance * cullingDistance;
_candidates.Clear();
_candidateDistances.Clear();
foreach (var kv in _props)
{
var distanceSq = haveCam ? (kv.Value.Position - camPos).sqrMagnitude : 0f;
if (cull && distanceSq > cullSqr) continue;
_candidates.Add(kv.Key);
_candidateDistances.Add(distanceSq);
}
SelectNearest(_candidateDistances, maxProxies, _selected);
// A silently truncated pool reads as "collision is broken" and is miserable to diagnose,
// so say it out loud — once, not every reconcile.
if (maxProxies > 0 && _candidates.Count > maxProxies)
{
if (!_capWarned)
{
_capWarned = true;
Debug.LogWarning($"{LogPrefix} StaticColliderBridge: {_candidates.Count} props are in range but " +
$"Max Proxies is {maxProxies} — the {_candidates.Count - maxProxies} furthest do NOT block the " +
"player. Raise Max Proxies, or lower Culling Distance so fewer compete.", this);
}
}
else
{
_capWarned = false;
}
_seen.Clear();
for (var i = 0; i < _selected.Count; i++)
{
var entity = _candidates[_selected[i]];
_seen.Add(entity);
if (_proxies.TryGetValue(entity, out var existing) && existing != null) continue; // static: place once
_proxies[entity] = CreateProxy(entity, _props[entity]);
}
_toRemove.Clear();
foreach (var kv in _proxies)
if (kv.Value == null || !_seen.Contains(kv.Key)) _toRemove.Add(kv.Key);
for (var i = 0; i < _toRemove.Count; i++)
{
if (_proxies.TryGetValue(_toRemove[i], out var go) && go != null) Destroy(go);
_proxies.Remove(_toRemove[i]);
}
}
///
/// Picks which candidates get a proxy when more are in range than the cap allows: nearest
/// first, ties broken by index so the choice is stable frame to frame. A cap of 0 or less
/// means "no cap". Pure and static so it can be unit tested without a world.
///
public static void SelectNearest(List distancesSq, int maxCount, List result)
{
result.Clear();
if (distancesSq == null) return;
for (var i = 0; i < distancesSq.Count; i++) result.Add(i);
if (maxCount <= 0 || result.Count <= maxCount) return;
result.Sort((a, b) =>
{
var compare = distancesSq[a].CompareTo(distancesSq[b]);
return compare != 0 ? compare : a.CompareTo(b);
});
result.RemoveRange(maxCount, result.Count - maxCount);
}
private GameObject CreateProxy(Entity entity, in PropInfo info)
{
var root = new GameObject("StaticColliderProxy");
root.transform.SetParent(_container, false);
root.transform.SetPositionAndRotation(info.Position, info.Rotation);
root.transform.localScale = info.LossyScale;
var buffer = _em.GetBuffer(entity, true);
for (var i = 0; i < buffer.Length; i++)
{
var element = buffer[i];
// One child per baked collider: each carries its own rotation relative to the prop,
// which a shared GameObject could not express.
var child = new GameObject("Collider");
child.layer = element.Layer;
child.transform.SetParent(root.transform, false);
child.transform.localPosition = element.LocalPosition;
child.transform.localRotation = element.LocalRotation;
child.transform.localScale = element.LocalScale;
AttachCollider(child, element);
}
return root;
}
private static void AttachCollider(GameObject target, in ProxyColliderElement element)
{
var isTrigger = element.IsTrigger == 1;
switch (element.Shape)
{
case ProxyColliderShape.Sphere:
var sphere = target.AddComponent();
sphere.center = element.Center;
sphere.radius = element.Radius;
sphere.isTrigger = isTrigger;
break;
case ProxyColliderShape.Capsule:
var capsule = target.AddComponent();
capsule.center = element.Center;
capsule.radius = element.Radius;
capsule.height = element.Height;
capsule.direction = element.Direction;
capsule.isTrigger = isTrigger;
break;
default:
var box = target.AddComponent();
box.center = element.Center;
box.size = element.Size;
box.isTrigger = isTrigger;
break;
}
}
/// Diagnostics: how many props are currently backed by real colliders.
public int ActiveProxyCount => _proxies.Count;
}
}