using jeanf.scenemanagement;
using jeanf.validationTools;
using Unity.Entities;
using Unity.Mathematics;
using UnityEngine;
namespace AutomaticDoorSystem
{
public class DoorAuthoring : MonoBehaviour, IValidatable
{
[Tooltip("REQUIRED: Unique identifier for this specific door instance (used for lock/unlock events)")]
public int doorId = 0;
[Tooltip("Whether THIS door starts locked. Use Config takes the DoorConfig's Start Locked; Force On/Off " +
"override it here so a locked variant of a door no longer needs its own config asset.")]
public ConfigBoolOverride startLockedOverride = ConfigBoolOverride.UseConfig;
[Tooltip("Which side of THIS door counts as FRONT for Forward-style rotating doors. Use Config takes " +
"the DoorConfig's Invert Forward Side; Force On/Off override it for this door only - handy " +
"when one prefab sharing a config was modelled with its pivot the other way round, or one " +
"placed instance is mirrored. The FRONT/BACK gizmo and the side probe reflect the result.")]
public ConfigBoolOverride invertForwardSideOverride = ConfigBoolOverride.UseConfig;
/// Per-door override of a DoorConfig bool: keep the config's value, or force it.
public enum ConfigBoolOverride
{
UseConfig = 0,
ForceOff = 1,
ForceOn = 2
}
[Tooltip("The actual door mesh GameObject to animate (for single doors)")]
public Transform doorMesh;
[Tooltip("For double doors: Left and Right door meshes")]
public Transform leftDoorMesh;
public Transform rightDoorMesh;
[Tooltip("Child GameObject containing the trigger volume (should have DoorTriggerVolumeAuthoring component)")]
public Transform triggerVolumeObject;
[Header("Debug Settings")]
[Tooltip("Enable debug visualization in scene view for this specific door instance")]
public bool enableDebug = false;
[Validation("A DoorConfig is required — baking SKIPS this door entirely without one.")]
[Tooltip("REQUIRED: Reference to the shared DoorConfig asset that defines this door's behavior")]
public DoorConfig doorConfig;
[Tooltip("Audio settings for this door (open/close/lock clips + spatialization). " +
"Baked into the entity, so the pooled AudioSources pick it up without a companion object in the main scene. " +
"Leave empty for a silent door.")]
public DoorAudioConfiguration doorAudioConfig;
[Tooltip("Optional anchor for this door's sound. When assigned, the pooled AudioSource is placed " +
"at this transform instead of the trigger volume centre. Its position is baked, so move it " +
"in edit mode, not at runtime.")]
public Transform audioAnchor;
[Tooltip("Where the player is dropped when the room this door closes gets locked at the end of a scenario. " +
"Place it on the SAFE side of the doorway, ideally inside the trigger volume so the door opens for " +
"them. The room itself is deduced from Door Id (it is the room number). Leave empty on doors that " +
"guard nothing.")]
public Transform exitAnchor;
///
/// Panel wiring must match the assigned config: a Double config needs BOTH panels, a Single
/// config needs the door mesh — otherwise nothing animates. Also invalid: a panel whose
/// SteamAudioDynamicObject has no exported asset — the pooled panel proxy would then carry
/// no Steam Audio geometry and the moving door silently stops occluding sound (select the
/// panel and click 'Export Dynamic Object'). Surfaced through the propertyDrawer
/// validation framework (inspector banner, hierarchy highlight, play-mode console scan).
/// A null config returns true here because the [Validation] field above already flags it.
/// Cross-scene rules (duplicate ids, managers present, subscene placement) stay in the
/// DoorSetupValidatorWindow — a single component cannot see them.
///
public bool IsValid
{
get
{
if (HasUnexportedAudioGeometry(doorMesh) || HasUnexportedAudioGeometry(leftDoorMesh) ||
HasUnexportedAudioGeometry(rightDoorMesh)) return false;
if (doorConfig == null) return true;
return doorConfig.doorCount == DoorConfig.DoorCountEnum.Double
? leftDoorMesh != null && rightDoorMesh != null
: doorMesh != null;
}
}
private static bool HasUnexportedAudioGeometry(Transform panel)
{
return panel != null
&& panel.TryGetComponent(out var dynamicObject)
&& dynamicObject.asset == null;
}
private static bool Resolve(ConfigBoolOverride overrideValue, bool configValue)
{
switch (overrideValue)
{
case ConfigBoolOverride.ForceOn: return true;
case ConfigBoolOverride.ForceOff: return false;
default: return configValue;
}
}
/// What the baker bakes as InvertForwardSide: the config's flag unless overridden here.
public bool EffectiveInvertForwardSide =>
Resolve(invertForwardSideOverride, doorConfig != null && doorConfig.invertForwardSide);
/// What the baker bakes as the initial lock state: the config's flag unless overridden here.
public bool EffectiveStartLocked =>
Resolve(startLockedOverride, doorConfig != null && doorConfig.startLocked);
///
/// Root-local point the approach-side split plane passes through: the mean of the assigned
/// panel pivots (hinges sit on the doorway; sliding panels sit in it). Falls back to the
/// root origin when no panel is assigned. Baked into DoorComponent.SidePlaneLocalOrigin.
///
public Vector3 SidePlaneLocalOrigin
{
get
{
var isDouble = doorConfig != null && doorConfig.doorCount == DoorConfig.DoorCountEnum.Double;
var sum = Vector3.zero;
var count = 0;
if (isDouble)
{
Accumulate(leftDoorMesh, ref sum, ref count);
Accumulate(rightDoorMesh, ref sum, ref count);
}
else
{
Accumulate(doorMesh, ref sum, ref count);
}
return count == 0 ? Vector3.zero : sum / count;
}
}
private void Accumulate(Transform panel, ref Vector3 sum, ref int count)
{
if (panel == null) return;
sum += transform.InverseTransformPoint(panel.position);
count++;
}
/// World position of .
public Vector3 SidePlaneWorldOrigin => transform.TransformPoint(SidePlaneLocalOrigin);
///
/// World-space unit direction pointing into this door's FRONT half-space (local +Z, or -Z
/// when inverted), i.e. the side the runtime reads as DirectionForward = 1.
///
public Vector3 FrontDirectionWorld
{
get
{
var forward = transform.TransformDirection(Vector3.forward);
if (forward.sqrMagnitude < 1e-8f) forward = Vector3.forward;
forward.Normalize();
return EffectiveInvertForwardSide ? -forward : forward;
}
}
///
/// Runtime-identical approach-side test for a world point (see ).
/// Returns 1 for FRONT, 0 for BACK, and how deep the point sits in front of the doorway plane
/// in the root's local units (negative = behind).
///
public byte DirectionForwardFor(Vector3 worldPoint, out float frontDepth)
{
var m = transform.worldToLocalMatrix;
var worldToDoor = new float4x4(m.GetColumn(0), m.GetColumn(1), m.GetColumn(2), m.GetColumn(3));
float3 origin = SidePlaneLocalOrigin;
float3 point = worldPoint;
frontDepth = DoorSideMath.FrontDepth(in worldToDoor, in origin, in point);
return DoorSideMath.DirectionForward(frontDepth, EffectiveInvertForwardSide);
}
/// Runtime-identical trigger-volume test for a world point.
public bool IsInsideTriggerVolume(Vector3 worldPoint)
{
var size = new Vector3(3f, 3f, 3f);
var worldCenter = transform.position;
if (triggerVolumeObject != null)
{
var volume = triggerVolumeObject.GetComponent();
worldCenter = triggerVolumeObject.TransformPoint(volume != null ? volume.volumeCenter : Vector3.zero);
if (volume != null) size = volume.volumeSize;
}
float3 point = worldPoint;
float3 center = worldCenter;
float3 volumeSize = size;
return DoorSideMath.IsInsideVolume(in point, in center, in volumeSize);
}
///
/// Editor hook: returns a panel's authored (closed) world rotation while an edit-mode
/// preview is posing it, so the swing-arc gizmos stay where the door path is instead of
/// turning with the panel. Null = not previewing, use the live rotation.
///
public static System.Func AuthoredWorldRotationProvider;
/// Same for the authored (closed) world position, so slide arrows stay put while a panel is previewed.
public static System.Func AuthoredWorldPositionProvider;
private static Quaternion ClosedWorldRotation(Transform panel)
{
var provided = AuthoredWorldRotationProvider?.Invoke(panel);
return provided ?? panel.rotation;
}
private static Vector3 ClosedWorldPosition(Transform panel)
{
var provided = AuthoredWorldPositionProvider?.Invoke(panel);
return provided ?? panel.position;
}
/// Scene camera side while gizmos are drawn; FRONT when there is no camera.
private bool CameraIsOnFront()
{
var camera = Camera.current;
return camera == null || DirectionForwardFor(camera.transform.position, out _) == 1;
}
///
/// True when the approach side decides the swing for this door: rotating, Forward style
/// (or BothWay on a single, which the runtime treats as Forward).
///
public bool ApproachSideMatters
{
get
{
if (doorConfig == null || doorConfig.doorMovement != DoorConfig.DoorMovementEnum.Rotating) return false;
if (doorConfig.openingStyle == DoorConfig.OpeningStyle.Forward) return true;
return doorConfig.openingStyle == DoorConfig.OpeningStyle.BothWay &&
doorConfig.doorCount == DoorConfig.DoorCountEnum.Single;
}
}
///
/// World position the pooled AudioSource is placed at: the audioAnchor override when one is
/// assigned, otherwise the centre of the trigger volume, which sits in the doorway rather
/// than at the door's pivot (usually a hinge edge).
/// Falls back to the door root when neither is assigned.
///
public Vector3 GetAudioAnchorPosition()
{
if (audioAnchor != null) return audioAnchor.position;
if (triggerVolumeObject == null) return transform.position;
var volumeAuthoring = triggerVolumeObject.GetComponent();
var localCenter = volumeAuthoring != null ? volumeAuthoring.volumeCenter : Vector3.zero;
return triggerVolumeObject.TransformPoint(localCenter);
}
#region Zone evacuation
///
/// This door offers a way out as soon as an anchor is placed. Which room it frees is not
/// authored here: is that room's number, resolved at runtime.
///
public bool HasZoneExit => exitAnchor != null;
#if UNITY_EDITOR
///
/// Editor-only preview of what the runtime will deduce: the Zone asset whose zoneNb equals
/// this door's id. Scans the project's Zone assets, so it works even when no volume
/// SubScene is open. Null when no room carries that number (a corridor-to-corridor door).
///
public Zone ResolveZoneFromDoorId()
{
if (doorId == 0) return null;
return ZonesByNumber().TryGetValue(doorId, out var zone) ? zone : null;
}
// The gizmo asks on every repaint, so the project-wide asset scan is cached and only
// refreshed every few seconds - long enough to pick up a zoneNb edited in another window.
private static readonly System.Collections.Generic.Dictionary ZoneNumberCache = new();
private static double _zoneCacheTime = double.MinValue;
private static System.Collections.Generic.Dictionary ZonesByNumber()
{
var now = UnityEditor.EditorApplication.timeSinceStartup;
if (now - _zoneCacheTime < 5d && ZoneNumberCache.Count > 0) return ZoneNumberCache;
_zoneCacheTime = now;
ZoneNumberCache.Clear();
var guids = UnityEditor.AssetDatabase.FindAssets("t:Zone");
for (var i = 0; i < guids.Length; i++)
{
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guids[i]);
var zone = UnityEditor.AssetDatabase.LoadAssetAtPath(path);
if (zone != null && zone.zoneNb != 0) ZoneNumberCache.TryAdd(zone.zoneNb, zone);
}
return ZoneNumberCache;
}
#endif
#endregion
private void OnDrawGizmosSelected()
{
if (doorConfig == null)
{
#if UNITY_EDITOR
var errorPos = transform.position + Vector3.up * 2f;
UnityEditor.Handles.Label(errorPos,
"ERROR: DoorConfig is NULL!\nAssign a DoorConfig asset to this door.",
new UnityEngine.GUIStyle()
{
normal = new UnityEngine.GUIStyleState() { textColor = Color.red, background = MakeTex(2, 2, new Color(0.5f, 0, 0, 0.9f)) },
fontSize = 12,
fontStyle = FontStyle.Bold,
padding = new UnityEngine.RectOffset(8, 8, 5, 5)
});
#endif
return;
}
Gizmos.matrix = Matrix4x4.identity;
var isDouble = doorConfig.doorCount == DoorConfig.DoorCountEnum.Double;
var isRotating = doorConfig.doorMovement == DoorConfig.DoorMovementEnum.Rotating;
if (isRotating)
{
DrawRotatingDoorGizmos(isDouble);
}
else
{
DrawSlidingDoorGizmos(isDouble);
}
DrawTriggerVolumeGizmos();
DrawAudioAnchorGizmo();
DrawZoneEvacuationGizmos();
if (ApproachSideMatters) DrawFrontSideGizmos();
if (enableDebug)
{
DrawDebugInfo();
}
}
///
/// Shows, before play mode, which side of the doorway the viewer is on: the split plane
/// and an arrow into each half-space, the one the Scene camera is in drawn solid (green =
/// front, red = back), the other faint. Same math as the runtime (full root matrix +
/// panel-pivot plane + invert flag), so if the solid arrow is on the wrong side, flip
/// Invert Forward Side. The inspector's Edit-Mode Door Test spells out the side in words.
///
private void DrawFrontSideGizmos()
{
var origin = SidePlaneWorldOrigin;
var front = FrontDirectionWorld;
var right = transform.TransformDirection(Vector3.right);
if (right.sqrMagnitude < 1e-8f) right = Vector3.right;
right.Normalize();
var isDouble = doorConfig.doorCount == DoorConfig.DoorCountEnum.Double;
var doorwayWidth = isDouble
? GetDoorWidth(leftDoorMesh) + GetDoorWidth(rightDoorMesh)
: GetDoorWidth(doorMesh);
var halfWidth = Mathf.Max(doorwayWidth * 0.5f, 0.4f);
var reach = Mathf.Clamp(halfWidth, 0.6f, 2f);
var cameraOnFront = CameraIsOnFront();
// The split plane, drawn as a line across the doorway at pivot height.
Gizmos.color = new Color(1f, 1f, 1f, 0.6f);
Gizmos.DrawLine(origin - right * halfWidth, origin + right * halfWidth);
DrawSideArrow(origin, front * reach, FrontColor, cameraOnFront);
DrawSideArrow(origin, -front * reach, BackColor, !cameraOnFront);
}
private void DrawSideArrow(Vector3 origin, Vector3 offset, Color color, bool highlighted)
{
var tip = origin + offset;
Gizmos.color = highlighted ? color : new Color(color.r, color.g, color.b, 0.25f);
Gizmos.DrawLine(origin, tip);
var direction = offset.normalized;
var headLength = Mathf.Min(0.25f, offset.magnitude * 0.3f);
var headRight = Quaternion.LookRotation(direction) * Quaternion.Euler(0, 180 + 25f, 0) * Vector3.forward;
var headLeft = Quaternion.LookRotation(direction) * Quaternion.Euler(0, 180 - 25f, 0) * Vector3.forward;
Gizmos.DrawLine(tip, tip + headRight * headLength);
Gizmos.DrawLine(tip, tip + headLeft * headLength);
if (highlighted) Gizmos.DrawWireSphere(tip, headLength * 0.35f);
}
private void DrawAudioAnchorGizmo()
{
if (audioAnchor == null) return;
Gizmos.matrix = Matrix4x4.identity;
Gizmos.color = Color.yellow;
Gizmos.DrawWireSphere(audioAnchor.position, 0.15f);
#if UNITY_EDITOR
UnityEditor.Handles.Label(audioAnchor.position + Vector3.up * 0.25f, "Audio Anchor");
#endif
}
///
/// The evacuation anchor, drawn as soon as one is assigned: where the player lands, which
/// way they face, and which zone this exit frees. It turns orange outside the trigger
/// volume - the door would not even see the player it just received, and would stay shut
/// in their face.
///
private void DrawZoneEvacuationGizmos()
{
if (exitAnchor == null) return;
var position = exitAnchor.position;
var reachable = IsInsideTriggerVolume(position);
var color = reachable ? new Color(.2f, .8f, 1f) : new Color(1f, .5f, .1f);
Gizmos.matrix = Matrix4x4.identity;
Gizmos.color = color;
Gizmos.DrawWireSphere(position, .35f);
Gizmos.DrawLine(position, position + Vector3.up * 1.7f);
Gizmos.DrawRay(position + Vector3.up * .1f, exitAnchor.forward * .6f);
Gizmos.DrawLine(position, GetAudioAnchorPosition());
#if UNITY_EDITOR
var warning = reachable
? string.Empty
: "\nOUTSIDE the trigger volume: the door will not open for the player dropped here.";
var room = ResolveZoneFromDoorId();
UnityEditor.Handles.color = color;
UnityEditor.Handles.Label(position + Vector3.up * 1.9f,
$"Zone exit (door {doorId})\nfrees: " +
$"{(room != null ? room.zoneName : "")}{warning}");
#endif
}
private void DrawTriggerVolumeGizmos()
{
if (triggerVolumeObject == null) return;
var volumeAuthoring = triggerVolumeObject.GetComponent();
if (volumeAuthoring == null) return;
DoorTriggerVolumeAuthoring.DrawVolumeGizmos(
triggerVolumeObject, volumeAuthoring.volumeCenter, volumeAuthoring.volumeSize, true,
audioAnchor == null);
Gizmos.matrix = Matrix4x4.identity;
}
private void DrawDebugInfo()
{
#if UNITY_EDITOR
if (doorConfig == null) return;
if (doorConfig.doorMovement == DoorConfig.DoorMovementEnum.Sliding)
{
if (doorConfig.doorCount == DoorConfig.DoorCountEnum.Single && doorMesh != null)
{
var initialPos = ClosedWorldPosition(doorMesh);
Gizmos.color = Color.red;
Gizmos.DrawWireSphere(initialPos, 0.15f);
UnityEditor.Handles.Label(initialPos + Vector3.up * 0.3f, "Closed Position",
new UnityEngine.GUIStyle() { normal = new UnityEngine.GUIStyleState() { textColor = Color.red } });
var targetPos = initialPos + SlideVectorToWorld(transform, doorConfig.slideOpenOffset);
Gizmos.color = Color.green;
Gizmos.DrawWireSphere(targetPos, 0.15f);
UnityEditor.Handles.Label(targetPos + Vector3.up * 0.3f, "Open Position",
new UnityEngine.GUIStyle() { normal = new UnityEngine.GUIStyleState() { textColor = Color.green } });
Gizmos.color = Color.cyan;
Gizmos.DrawLine(initialPos, targetPos);
}
else if (doorConfig.doorCount == DoorConfig.DoorCountEnum.Double)
{
if (leftDoorMesh != null)
{
var leftInitialPos = ClosedWorldPosition(leftDoorMesh);
var leftTargetPos = leftInitialPos + SlideVectorToWorld(transform, doorConfig.slideOpenOffset);
Gizmos.color = Color.red;
Gizmos.DrawWireSphere(leftInitialPos, 0.12f);
Gizmos.color = Color.green;
Gizmos.DrawWireSphere(leftTargetPos, 0.12f);
Gizmos.color = Color.cyan;
Gizmos.DrawLine(leftInitialPos, leftTargetPos);
}
if (rightDoorMesh != null)
{
var rightInitialPos = ClosedWorldPosition(rightDoorMesh);
var rightTargetPos = rightInitialPos +
SlideVectorToWorld(transform, MirrorForRightPanel(doorConfig.slideOpenOffset));
Gizmos.color = Color.red;
Gizmos.DrawWireSphere(rightInitialPos, 0.12f);
Gizmos.color = Color.green;
Gizmos.DrawWireSphere(rightTargetPos, 0.12f);
Gizmos.color = Color.cyan;
Gizmos.DrawLine(rightInitialPos, rightTargetPos);
}
}
var doorRoot = transform.position + Vector3.up * 2.5f;
UnityEditor.Handles.Label(doorRoot,
$"Door ID: {doorId}\n" +
$"Config: {doorConfig.name}\n" +
$"Type: {doorConfig.doorCount} {doorConfig.doorMovement}\n" +
$"Slide Offset: {doorConfig.slideOpenOffset}\n" +
$"Animation: {doorConfig.animationDuration}s\n" +
$"Auto-Close: {doorConfig.autoCloseDelay}s\n" +
$"DEBUG ENABLED",
new UnityEngine.GUIStyle()
{
normal = new UnityEngine.GUIStyleState() { textColor = Color.white, background = MakeTex(2, 2, new Color(0, 0, 0, 0.7f)) },
fontSize = 11,
padding = new UnityEngine.RectOffset(5, 5, 3, 3)
});
}
#endif
}
#if UNITY_EDITOR
private Texture2D MakeTex(int width, int height, Color col)
{
Color[] pix = new Color[width * height];
for (int i = 0; i < pix.Length; i++)
pix[i] = col;
Texture2D result = new Texture2D(width, height);
result.SetPixels(pix);
result.Apply();
return result;
}
#endif
private static readonly Color FrontColor = Color.green;
private static readonly Color BackColor = Color.red;
///
/// The swing path of each panel, drawn from the CLOSED rotation so it stays fixed while
/// the preview animates. The angle per panel is exactly what DoorAnimationSystem applies:
/// Forward - single: +fwd from the front, bwd from the back; double: the left panel takes
/// bwd from the front / fwd from the back and the right panel the negated angle.
/// BothWay - double: BOTH panels take the forward angle, un-mirrored (one swings each
/// way because the panels face each other); single: behaves as Forward.
/// OneWay - bwd when One Way Direction z >= 0, else fwd; double right panel negated.
/// Forward-style arcs show the swing for the Scene camera's side (green = front, red =
/// back); OneWay is red (fixed swing), double BothWay cyan. Each panel's closed edge is
/// read off its own geometry, not assumed from the left/right slot.
///
private void DrawRotatingDoorGizmos(bool isDouble)
{
if (doorConfig == null) return;
var style = doorConfig.openingStyle;
var cameraOnFront = !ApproachSideMatters || CameraIsOnFront();
var sideColor = cameraOnFront ? FrontColor : BackColor;
var fwd = doorConfig.openForwardAngle;
var bwd = doorConfig.openBackwardAngle;
var oneWayAngle = doorConfig.oneWayDirection.z >= 0 ? bwd : fwd;
if (isDouble)
{
float leftAngle, rightAngle;
Color color;
switch (style)
{
case DoorConfig.OpeningStyle.BothWay:
leftAngle = rightAngle = fwd;
color = Color.cyan;
break;
case DoorConfig.OpeningStyle.OneWay:
leftAngle = oneWayAngle;
rightAngle = -oneWayAngle;
color = BackColor;
break;
default:
leftAngle = cameraOnFront ? bwd : fwd;
rightAngle = -leftAngle;
color = sideColor;
break;
}
DrawPanelSwing(leftDoorMesh, leftAngle, color);
DrawPanelSwing(rightDoorMesh, rightAngle, color);
}
else
{
var angle = style == DoorConfig.OpeningStyle.OneWay ? oneWayAngle : (cameraOnFront ? fwd : bwd);
var color = style == DoorConfig.OpeningStyle.OneWay ? BackColor : sideColor;
DrawPanelSwing(doorMesh, angle, color);
}
}
private void DrawPanelSwing(Transform panel, float localYAngle, Color color)
{
if (panel == null) return;
DrawSwingArc(ClosedWorldPosition(panel), ClosedWorldRotation(panel), ClosedEdgeLocal(panel), localYAngle, color,
GetDoorWidth(panel));
}
///
/// Which way the panel extends from its pivot along its own X, from the collider or
/// renderer bounds: +X (Vector3.right) or -X (Vector3.left). Prefabs differ here, so the
/// left/right slot must not be used to guess it.
///
private static Vector3 ClosedEdgeLocal(Transform panel)
{
Vector3 center;
var box = panel.GetComponent();
if (box == null) box = panel.GetComponentInChildren();
if (box != null)
{
center = box.transform.TransformPoint(box.center);
}
else
{
var renderer = panel.GetComponent();
if (renderer == null) renderer = panel.GetComponentInChildren();
if (renderer == null) return Vector3.right;
center = renderer.bounds.center;
}
return Vector3.Dot(center - panel.position, panel.right) < 0f ? Vector3.left : Vector3.right;
}
private float GetDoorWidth(Transform doorTransform)
{
if (doorTransform == null) return 1.5f;
// Try BoxCollider first (most accurate for door panels)
var boxCollider = doorTransform.GetComponent();
if (boxCollider == null)
{
boxCollider = doorTransform.GetComponentInChildren();
}
if (boxCollider != null)
{
// Door width is typically local X, accounting for scale
return boxCollider.size.x * doorTransform.lossyScale.x;
}
// Fallback to renderer bounds
var renderer = doorTransform.GetComponent();
if (renderer == null)
{
renderer = doorTransform.GetComponentInChildren();
}
if (renderer != null)
{
return renderer.bounds.size.x;
}
return 1.5f;
}
///
/// The path a panel sweeps on the ground: a translucent sector with a thick outline at
/// the hinge pivot, from the panel's closed edge to its open edge, labelled with the
/// angle. Drawn on top of geometry so the floor cannot hide it. Handles-based, editor only.
///
private void DrawSwingArc(Vector3 pivot, Quaternion closedRotation, Vector3 closedEdgeLocal,
float angle, Color color, float radius)
{
#if UNITY_EDITOR
var up = closedRotation * Vector3.up;
var from = closedRotation * closedEdgeLocal;
var center = pivot;
var end = Quaternion.AngleAxis(angle, up) * from;
UnityEditor.Handles.zTest = UnityEngine.Rendering.CompareFunction.Always;
UnityEditor.Handles.color = new Color(color.r, color.g, color.b, 0.4f);
UnityEditor.Handles.DrawSolidArc(center, up, from, angle, radius);
UnityEditor.Handles.color = color;
UnityEditor.Handles.DrawWireArc(center, up, from, angle, radius, 4f);
UnityEditor.Handles.DrawLine(center, center + end * radius, 4f);
UnityEditor.Handles.DrawLine(center, center + from * radius, 1.5f);
UnityEditor.Handles.Label(center + end * radius + up * 0.1f, $"{angle:F0}°",
new GUIStyle
{
normal = new GUIStyleState { textColor = color },
fontSize = 12,
fontStyle = FontStyle.Bold
});
#endif
}
///
/// Rotates a door-local slide offset into world space for drawing. Uses TransformVector
/// rather than TransformDirection so a scaled door root scales its travel, matching how
/// the panels' local positions scale with it.
///
public static Vector3 SlideVectorToWorld(Transform doorRoot, Vector3 localSlideOffset)
{
return doorRoot == null ? localSlideOffset : doorRoot.TransformVector(localSlideOffset);
}
///
/// The right panel of a Mirrored double slides the opposite way along the door's own X,
/// which is the negation DoorAnimationSystem applies to SlideOffset.x at runtime.
///
public static Vector3 MirrorForRightPanel(Vector3 localSlideOffset)
{
return new Vector3(-localSlideOffset.x, localSlideOffset.y, localSlideOffset.z);
}
private void DrawSlidingDoorGizmos(bool isDouble)
{
if (doorConfig == null) return;
// The config offsets are door-local, so rotate them into world space to draw them.
// This used to be TransformVector(InverseTransformVector(v)) - an exact no-op that
// drew the raw world vector and so ignored the door's rotation entirely.
Vector3 worldOffset = SlideVectorToWorld(transform, doorConfig.slideOpenOffset);
if (isDouble && doorConfig.slidingStyle == DoorConfig.SlidingStyleEnum.Telescopic)
{
// Both panels travel the same direction; the right one leads and goes further.
// With openRightDoorOnly the right arrow stops at the catch-up point (where the
// left door sits) and the left door does not move at all.
if (leftDoorMesh != null && !doorConfig.openRightDoorOnly)
{
DrawSlideArrow(ClosedWorldPosition(leftDoorMesh), worldOffset, Color.cyan);
}
if (rightDoorMesh != null)
{
var rightOffset = doorConfig.rightSlideOpenOffset;
if (doorConfig.openRightDoorOnly)
{
var rightSpan = rightOffset.magnitude;
var catchUp = Mathf.Max(rightSpan - doorConfig.slideOpenOffset.magnitude, 0f);
rightOffset = rightSpan > 1e-4f ? rightOffset / rightSpan * catchUp : Vector3.zero;
}
DrawSlideArrow(ClosedWorldPosition(rightDoorMesh), SlideVectorToWorld(transform, rightOffset), Color.cyan);
}
}
else if (isDouble)
{
if (leftDoorMesh != null)
{
DrawSlideArrow(ClosedWorldPosition(leftDoorMesh), worldOffset, Color.cyan);
}
if (rightDoorMesh != null)
{
// Right door uses negated X in local space (matches animation system)
DrawSlideArrow(ClosedWorldPosition(rightDoorMesh),
SlideVectorToWorld(transform, MirrorForRightPanel(doorConfig.slideOpenOffset)),
Color.cyan);
}
}
else
{
if (doorMesh != null)
{
DrawSlideArrow(ClosedWorldPosition(doorMesh), worldOffset, Color.cyan);
}
else
{
DrawSlideArrow(transform.position, worldOffset, Color.cyan);
}
}
}
private void DrawSlideArrow(Vector3 startPos, Vector3 offset, Color color)
{
Gizmos.color = color;
Vector3 endPos = startPos + offset;
Gizmos.DrawLine(startPos, endPos);
Vector3 direction = offset.normalized;
float arrowHeadLength = 0.3f;
float arrowHeadAngle = 25f;
Vector3 right = Quaternion.LookRotation(direction) * Quaternion.Euler(0, 180 + arrowHeadAngle, 0) * Vector3.forward;
Vector3 left = Quaternion.LookRotation(direction) * Quaternion.Euler(0, 180 - arrowHeadAngle, 0) * Vector3.forward;
Gizmos.DrawLine(endPos, endPos + right * arrowHeadLength);
Gizmos.DrawLine(endPos, endPos + left * arrowHeadLength);
#if UNITY_EDITOR
float distance = offset.magnitude;
UnityEditor.Handles.Label(endPos, $"Slide\n{distance:F2}m");
#endif
}
class DoorBaker : Baker
{
public override void Bake(DoorAuthoring authoring)
{
if (authoring.doorConfig == null)
{
Debug.LogError($"[DoorBaker] Door '{authoring.gameObject.name}' is missing DoorConfig reference! Skipping baking.", authoring.gameObject);
return;
}
// Nearly everything below is read off this shared asset - door type, slide offsets,
// sliding style, timings, layer mask, start-locked. Without this dependency Unity
// never learns the entity is derived from it, so editing a DoorConfig left every
// door baked with stale values: the gizmos (live authoring data) moved while the
// baked entities did not, and only touching a door GameObject forced a rebake.
DependsOn(authoring.doorConfig);
var entity = GetEntity(TransformUsageFlags.Dynamic);
var doorId = authoring.doorId;
var debug = authoring.enableDebug;
var config = authoring.doorConfig;
var doorType = ComputeDoorType(config.doorCount, config.doorMovement);
var animDuration = config.animationDuration;
var autoClose = config.autoCloseDelay;
var layerMask = config.canOpenLayerMask.value;
var locked = authoring.EffectiveStartLocked;
Vector3 triggerSize;
Vector3 triggerCenter;
Transform triggerTransform;
if (authoring.triggerVolumeObject != null)
{
var volumeAuthoring = authoring.triggerVolumeObject.GetComponent();
if (volumeAuthoring != null)
{
triggerSize = volumeAuthoring.volumeSize;
triggerCenter = volumeAuthoring.volumeCenter;
triggerTransform = authoring.triggerVolumeObject;
}
else
{
Debug.LogWarning($"[DoorBaker] Door '{authoring.gameObject.name}' has triggerVolumeObject set but no DoorTriggerVolumeAuthoring component found. Using default trigger volume.", authoring.gameObject);
triggerSize = new Vector3(3f, 3f, 3f);
triggerCenter = Vector3.zero;
triggerTransform = authoring.triggerVolumeObject;
}
}
else
{
Debug.LogWarning($"[DoorBaker] Door '{authoring.gameObject.name}' has no triggerVolumeObject assigned. Using default trigger volume at door root.", authoring.gameObject);
triggerSize = new Vector3(3f, 3f, 3f);
triggerCenter = Vector3.zero;
triggerTransform = authoring.transform;
}
// Approach-side detection: the split plane goes through the panel pivots, in the
// root's frame, so it follows the door's real rotation and scale (the old yaw-
// quantized world axis misread any door turned off a cardinal angle or whose root
// sits off the doorway). The panel transforms are declared as dependencies further
// down, so moving a hinge rebakes this too.
AddComponent(entity, new DoorComponent
{
DoorId = doorId,
Type = doorType,
AnimationDuration = animDuration,
AutoCloseDelay = autoClose,
SidePlaneLocalOrigin = authoring.SidePlaneLocalOrigin,
InvertForwardSide = (byte)(authoring.EffectiveInvertForwardSide ? 1 : 0)
});
AddComponent(entity, new DoorStateComponent
{
CurrentState = DoorState.Closed,
PreviousState = DoorState.Closed,
StateTimer = 0f,
EntitiesInTrigger = 0,
IsLocked = (byte)(locked ? 1 : 0),
ShouldPlayOpenSound = 0,
ShouldPlayCloseSound = 0,
DirectionForward = 1
});
var worldCenter = triggerTransform.TransformPoint(triggerCenter);
var localCenterRelativeToRoot = authoring.transform.InverseTransformPoint(worldCenter);
AddComponent(entity, new DoorTriggerVolume
{
Size = triggerSize,
Center = localCenterRelativeToRoot,
LayerMask = layerMask
});
// Baked even when null: the runtime bridge then knows this door is deliberately silent
// instead of assuming the config just has not loaded yet.
if (authoring.doorAudioConfig != null)
{
DependsOn(authoring.doorAudioConfig);
}
float3 audioAnchorLocal;
if (authoring.audioAnchor != null)
{
DependsOn(authoring.audioAnchor);
audioAnchorLocal = authoring.transform.InverseTransformPoint(authoring.audioAnchor.position);
}
else
{
audioAnchorLocal = localCenterRelativeToRoot;
}
AddComponent(entity, new DoorAudioConfigReference
{
Config = authoring.doorAudioConfig,
AnchorLocalPosition = audioAnchorLocal
});
BakeZoneExits(authoring, entity);
var transformData = CalculateTransformData(authoring, config);
AddComponent(entity, transformData);
if (debug)
{
AddComponent(entity);
}
if (doorType == DoorType.RotatingDouble || doorType == DoorType.SlidingDouble)
{
var buffer = AddBuffer(entity);
if (authoring.leftDoorMesh != null)
{
DependsOn(authoring.leftDoorMesh);
var leftEntity = GetEntity(authoring.leftDoorMesh, TransformUsageFlags.Dynamic);
var leftColliderData = ExtractColliderData(authoring.leftDoorMesh);
buffer.Add(new DoubleDoorBuffer
{
DoorEntity = leftEntity,
IsLeftDoor = 1,
ColliderSize = leftColliderData.size,
ColliderCenter = leftColliderData.center,
HasColliderData = leftColliderData.hasData,
InitialLocalPosition = authoring.leftDoorMesh.localPosition,
AudioGeometry = ExtractAudioGeometry(authoring.leftDoorMesh)
});
}
if (authoring.rightDoorMesh != null)
{
DependsOn(authoring.rightDoorMesh);
var rightEntity = GetEntity(authoring.rightDoorMesh, TransformUsageFlags.Dynamic);
var rightColliderData = ExtractColliderData(authoring.rightDoorMesh);
buffer.Add(new DoubleDoorBuffer
{
DoorEntity = rightEntity,
IsLeftDoor = 0,
ColliderSize = rightColliderData.size,
ColliderCenter = rightColliderData.center,
HasColliderData = rightColliderData.hasData,
InitialLocalPosition = authoring.rightDoorMesh.localPosition,
AudioGeometry = ExtractAudioGeometry(authoring.rightDoorMesh)
});
}
}
else if (authoring.doorMesh != null)
{
DependsOn(authoring.doorMesh);
var doorMeshEntity = GetEntity(authoring.doorMesh, TransformUsageFlags.Dynamic);
var colliderData = ExtractColliderData(authoring.doorMesh);
var buffer = AddBuffer(entity);
buffer.Add(new DoubleDoorBuffer
{
DoorEntity = doorMeshEntity,
IsLeftDoor = 0,
ColliderSize = colliderData.size,
ColliderCenter = colliderData.center,
HasColliderData = colliderData.hasData,
InitialLocalPosition = authoring.doorMesh.localPosition,
AudioGeometry = ExtractAudioGeometry(authoring.doorMesh)
});
}
}
private DoorType ComputeDoorType(DoorConfig.DoorCountEnum count, DoorConfig.DoorMovementEnum movement)
{
if (count == DoorConfig.DoorCountEnum.Single && movement == DoorConfig.DoorMovementEnum.Rotating)
return DoorType.RotatingSingle;
else if (count == DoorConfig.DoorCountEnum.Double && movement == DoorConfig.DoorMovementEnum.Rotating)
return DoorType.RotatingDouble;
else if (count == DoorConfig.DoorCountEnum.Single && movement == DoorConfig.DoorMovementEnum.Sliding)
return DoorType.SlidingSingle;
else
return DoorType.SlidingDouble;
}
///
/// Steam Audio geometry authored on the panel (SteamAudioDynamicObject + exported
/// asset). The component itself is stripped at bake; only the asset reference
/// survives, and the collider pool re-attaches it to the pooled panel proxy.
///
private UnityObjectRef ExtractAudioGeometry(Transform panelTransform)
{
var dynamicObject = panelTransform.GetComponent();
if (dynamicObject == null) return default;
DependsOn(dynamicObject);
if (dynamicObject.asset == null)
{
Debug.LogWarning($"[DoorAuthoring] '{panelTransform.name}' has a SteamAudioDynamicObject with no " +
"exported asset — the moving door will not occlude or reflect sound. Select the panel and " +
"click 'Export Dynamic Object', then re-bake.", panelTransform);
return default;
}
return dynamicObject.asset;
}
///
/// Bakes the authored exit anchor into a , so SceneManagement
/// can read it once the SubScene's GameObjects are gone. Nothing is added when the door
/// has no anchor, so doors that guard nothing stay as light as before. The room served
/// is NOT baked: it is deduced at runtime from the door id.
///
private void BakeZoneExits(DoorAuthoring authoring, Entity entity)
{
if (!authoring.HasZoneExit) return;
DependsOn(authoring.exitAnchor);
var localForward = authoring.transform.InverseTransformDirection(authoring.exitAnchor.forward);
AddComponent(entity, new DoorZoneExit
{
ExitLocalPosition = authoring.transform.InverseTransformPoint(authoring.exitAnchor.position),
ExitLocalForward = math.normalizesafe(localForward, new float3(0f, 0f, 1f))
});
}
private (float3 size, float3 center, byte hasData) ExtractColliderData(Transform panelTransform)
{
var boxCollider = panelTransform.GetComponent();
if (boxCollider == null)
{
boxCollider = panelTransform.GetComponentInChildren();
}
if (boxCollider != null)
{
DependsOn(boxCollider);
DependsOn(boxCollider.transform);
// The pooled proxy reproduces this box at the PANEL pivot's position+rotation,
// scale 1 - but door models author the BoxCollider on the mesh child under the
// animated CTRL node, whose local offset/rotation/scale a raw center/size copy
// silently drops (the collider then lands mirrored across the hinge at runtime,
// while edit mode - which uses the child's real transform - looks perfect).
// Fold the whole chain into the panel frame instead.
var worldSize = DoorColliderBake.WorldSize(boxCollider);
DoorColliderBake.DescribeInPanelFrame(
panelTransform.position, panelTransform.rotation,
DoorColliderBake.WorldCenter(boxCollider),
boxCollider.transform.rotation,
worldSize,
out var center, out var size, out var relativeAngle);
// Right-angle rotations reproduce exactly (ratio 1); only oblique ones bloat.
if (DoorColliderBake.BloatRatio(size, worldSize) > DoorColliderBake.BloatWarnRatio)
{
Debug.LogWarning(
$"[DoorBaker] Panel '{panelTransform.name}': its BoxCollider (on '{boxCollider.name}') is " +
$"rotated {relativeAngle:0}° relative to the panel pivot. The pooled collider can only be " +
"an axis-aligned box in the panel's frame, so the enclosing (larger) box is baked. Align " +
"the collider's node with the panel pivot for an exact fit.", boxCollider);
}
return (size, center, 1);
}
return (new float3(1f, 2.5f, 0.1f), new float3(0.5f, 1.25f, 0f), 0);
}
private DoorTransformData CalculateTransformData(DoorAuthoring authoring, DoorConfig config)
{
var data = new DoorTransformData();
var doorType = ComputeDoorType(config.doorCount, config.doorMovement);
if (doorType == DoorType.SlidingSingle || doorType == DoorType.SlidingDouble)
{
// Slide offsets are authored in the door root's LOCAL space and the animation
// system applies them to the panels' LocalTransform, so they are stored as-is.
// Converting through world here made a shared DoorConfig useless the moment two
// doors had different rotations: every door slid along the same world axis, so a
// door rotated 90 degrees slid across its doorway instead of along it.
var openOffset = config.slideOpenOffset;
data.SlideOffset = new float3(openOffset.x, openOffset.y, openOffset.z);
data.SlidingStyle = (SlidingStyle)config.slidingStyle;
var rightOffset = config.rightSlideOpenOffset;
data.RightSlideOffset = new float3(rightOffset.x, rightOffset.y, rightOffset.z);
data.OpenRightDoorOnly = (byte)(config.openRightDoorOnly ? 1 : 0);
if (doorType == DoorType.SlidingSingle && authoring.doorMesh != null)
{
data.InitialPosition = authoring.doorMesh.localPosition;
}
else
{
data.InitialPosition = float3.zero;
}
data.ClosedRotation = quaternion.identity;
data.OpenRotationForward = quaternion.identity;
data.OpenRotationBackward = quaternion.identity;
}
else
{
data.ClosedRotation = quaternion.identity;
var forwardAngle = config.openForwardAngle;
data.OpenRotationForward = Quaternion.Euler(0f, forwardAngle, 0f);
var backwardAngle = config.openBackwardAngle;
data.OpenRotationBackward = Quaternion.Euler(0f, backwardAngle, 0f);
data.OpeningStyle = (OpeningStyle)config.openingStyle;
var oneWayDir = config.oneWayDirection;
oneWayDir = oneWayDir.sqrMagnitude > 0.0001f ? oneWayDir.normalized : Vector3.forward;
data.OneWayDirection = new float3(oneWayDir.x, oneWayDir.y, oneWayDir.z);
data.SlideOffset = float3.zero;
data.InitialPosition = float3.zero;
}
return data;
}
}
}
}