wip: Path to Omnissiah

This commit is contained in:
Alexandre Coirier
2026-05-18 23:11:16 +02:00
parent c4ad46990b
commit 0ca3cafe96
36 changed files with 4194 additions and 6130 deletions
@@ -0,0 +1,30 @@
using UnityEngine;
[ExecuteInEditMode, DefaultExecutionOrder(1)]
public class Portal : MonoBehaviour
{
[SerializeField, Range(0, 2)] private int _id;
[SerializeField, Range(0, 1)] private float _oval;
public int ID => _id;
public float Oval => _oval;
private void OnDrawGizmos()
{
Gizmos.matrix = transform.localToWorldMatrix;
Gizmos.color = Color.cyan;
Gizmos.DrawWireCube(Vector3.zero, new Vector3(1, 1, 0));
}
void OnEnable()
{
if (PortalManager.Instance)
PortalManager.Instance.RegisterPortal(this);
}
void OnDisable()
{
if (PortalManager.Instance)
PortalManager.Instance.UnregisterPortal(this);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3b841c8d159a88e4a81dbf12ab26e346
@@ -0,0 +1,110 @@
using OctoCore;
using UnityEngine;
using UnityEngine.SocialPlatforms;
[ExecuteInEditMode, DefaultExecutionOrder(0)]
public class PortalManager : MonoBehaviour
{
public static PortalManager Instance { get; private set; }
[System.Serializable]
public class State
{
public bool PlayerInside;
public Portal[] Portals;
}
[SerializeField, OctoReadOnly] private State _state;
void OnEnable()
{
_state = new State();
_state.Portals = new Portal[3];
if (!Instance)
Instance = this;
ResetMasks();
}
void OnDisable()
{
if (Instance == this)
Instance = null;
ResetMasks();
}
void Update()
{
UpdateMasks();
}
public void SetInside(bool inside)
{
_state.PlayerInside = inside;
UpdateMasks();
}
public void RegisterPortal(Portal portal)
{
_state.Portals[portal.ID] = portal;
ManageMask(portal, true);
}
public void UnregisterPortal(Portal portal)
{
ManageMask(portal, false);
_state.Portals[portal.ID] = null;
}
public void ManageMask(Portal portal, bool active)
{
if (!portal)
return;
if (_state.Portals[portal.ID] != portal)
{
RegisterPortal(portal);
return;
}
Vector3 camPos = Camera.main ? Camera.main.transform.position : Vector3.zero;
Matrix4x4 m = new Matrix4x4();
m.SetRow(0, new Vector4(camPos.x, camPos.y, camPos.z, !active || _state.PlayerInside ? 0 : 1));
m.SetRow(1, new Vector4(portal.transform.position.x, portal.transform.position.y, portal.transform.position.z, portal.Oval));
m.SetRow(2, new Vector4(portal.transform.up.x, portal.transform.up.y, portal.transform.up.z, portal.transform.localScale.x));
m.SetRow(3, new Vector4(portal.transform.forward.x, portal.transform.forward.y, portal.transform.forward.z, portal.transform.localScale.y));
SetMask(portal.ID, m);
}
private void UpdateMasks()
{
for (int i = 0; i < _state.Portals.Length; i++)
ManageMask(_state.Portals[i], true);
}
private void ResetMasks()
{
for (int i = 0; i < _state.Portals.Length; i++)
{
Matrix4x4 m = new Matrix4x4();
m.SetRow(0, Vector4.zero);
m.SetRow(1, Vector4.zero);
m.SetRow(2, Vector4.zero);
m.SetRow(3, Vector4.zero);
SetMask(i, m);
}
}
private void SetMask(int id, Matrix4x4 m)
{
Shader.SetGlobalMatrix($"_Mask{id}", m);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 746134082760dc24e8ffdf666230032d
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: a7af0836297e37f40b61bdecbb261265
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,733 +0,0 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering.Universal;
using Unheimlich.Features.Portals;
using Unheimlich.Core.Utils;
namespace Unheimlich.Features.Portals
{
/// <summary>
/// Core component of the seamless portal system.
/// Creates a real-time view through to a linked portal and handles object teleportation.
///
/// How it works:
/// 1. Renders the view from the linked portal's perspective to a texture
/// 2. Displays that texture on this portal's screen mesh
/// 3. Tracks objects (PortalTravellers) that enter the portal
/// 4. Teleports them when they cross the portal plane
/// 5. Uses shader-based slicing to prevent visual artifacts during crossing
/// </summary>
[RequireComponent(typeof(BoxCollider))]
public class Portal : MonoBehaviour
{
/// <summary>Global list of all active portals in the scene</summary>
public static List<Portal> AllPortals = new List<Portal>();
[Header("Main Settings")]
[SerializeField] private string _portalId = "";
[SerializeField] private Portal _linkedPortal;
[SerializeField] private MeshRenderer _screen;
[SerializeField] private int _recursionLimit = 3;
[Header("Advanced Settings")]
[SerializeField] private float _nearClipOffset = 0.05f;
[SerializeField] private float _nearClipLimit = 0.2f;
[SerializeField] private float _transitionDistance = 0.5f;
[SerializeField] private float _loadRoomDistance = 30f;
[Header("Room Loading")]
[SerializeField] private GameObject _roomPrefabToLoad;
[SerializeField] private Transform _roomSpawnPoint;
[SerializeField] private string _linkedPortalPathInRoom = "Portal";
[SerializeField] private string _linkedPortalId = "";
private RenderTexture _viewTexture;
private Camera _portalCam;
private Camera _playerCam;
private List<PortalTraveller> _trackedTravellers;
private MeshFilter _screenMeshFilter;
private GameObject _loadedRoomInstance;
private string _roomId;
private PortalDoor _door;
private int _lastRenderedFrame = -1;
private bool _playerIsTeleporting = false;
public event Action RoomLoaded;
public event Action RoomUnloaded;
public Portal LinkedPortal => _linkedPortal;
public GameObject LoadedRoomInstance => _loadedRoomInstance;
public bool IsDoorOpen => _door == null || _door.IsOpen;
private void Awake()
{
_playerCam = Camera.main;
_portalCam = GetComponentInChildren<Camera>();
// Create portal camera if one doesn't exist
if (_portalCam == null)
{
GameObject camObj = new GameObject(PortalConstants.PORTAL_CAMERA_NAME);
camObj.transform.SetParent(transform);
camObj.transform.localPosition = Vector3.zero;
camObj.transform.localRotation = Quaternion.identity;
_portalCam = camObj.AddComponent<Camera>();
}
// Configure portal camera to match player camera settings
_portalCam.enabled = false;
_portalCam.depth = _playerCam.depth - 1;
_portalCam.clearFlags = _playerCam.clearFlags;
_portalCam.backgroundColor = _playerCam.backgroundColor;
_portalCam.farClipPlane = _playerCam.farClipPlane;
_portalCam.nearClipPlane = _playerCam.nearClipPlane;
_portalCam.fieldOfView = _playerCam.fieldOfView;
_portalCam.cullingMask = _playerCam.cullingMask;
_portalCam.allowHDR = _playerCam.allowHDR;
_portalCam.allowMSAA = _playerCam.allowMSAA;
_portalCam.useOcclusionCulling = _playerCam.useOcclusionCulling;
// Configure URP camera data for shadows and better quality
var urpCamData = _portalCam.GetUniversalAdditionalCameraData();
if (urpCamData != null)
{
// Copy main camera URP settings
var mainCamData = _playerCam.GetUniversalAdditionalCameraData();
if (mainCamData != null)
{
urpCamData.renderType = CameraRenderType.Base;
urpCamData.renderShadows = mainCamData.renderShadows;
urpCamData.requiresDepthOption = mainCamData.requiresDepthOption;
urpCamData.requiresColorOption = mainCamData.requiresColorOption;
urpCamData.requiresDepthTexture = mainCamData.requiresDepthTexture;
urpCamData.requiresColorTexture = mainCamData.requiresColorTexture;
urpCamData.renderPostProcessing = false;
urpCamData.antialiasing = mainCamData.antialiasing;
urpCamData.antialiasingQuality = mainCamData.antialiasingQuality;
urpCamData.volumeLayerMask = 0;
}
else
{
// Fallback if main camera doesn't have URP data
urpCamData.renderType = CameraRenderType.Base;
urpCamData.renderShadows = true;
urpCamData.requiresDepthTexture = true;
urpCamData.renderPostProcessing = false;
urpCamData.volumeLayerMask = 0;
}
}
_trackedTravellers = new List<PortalTraveller>();
_screenMeshFilter = _screen.GetComponent<MeshFilter>();
_screen.material.SetInt(PortalConstants.SHADER_PROPERTY_DISPLAY_MASK, 1);
_door = GetComponentInChildren<PortalDoor>();
if (_roomPrefabToLoad != null)
{
_roomId = $"{gameObject.name}_{_roomPrefabToLoad.name}";
}
}
private void OnEnable()
{
if (!AllPortals.Contains(this))
AllPortals.Add(this);
}
private void OnDisable()
{
if (AllPortals.Contains(this))
AllPortals.Remove(this);
}
private void Start()
{
if (PortalRoomManager.Instance != null && _roomPrefabToLoad != null)
{
PortalRoomManager.Instance.RegisterPortal(this);
}
}
private void OnDestroy()
{
if (PortalRoomManager.Instance != null && _roomPrefabToLoad != null)
{
PortalRoomManager.Instance.UnregisterPortal(this);
}
}
private void LateUpdate()
{
TryFindLinkedPortal();
HandleTravellers();
}
/// <summary>
/// Attempts to find and link to a portal in a dynamically loaded room.
/// Called each frame until a link is established.
/// </summary>
private void TryFindLinkedPortal()
{
if (_linkedPortal != null || PortalRoomManager.Instance == null || _roomPrefabToLoad == null)
return;
GameObject loadedRoom = PortalRoomManager.Instance.GetLoadedRoom(_roomId);
if (loadedRoom != null)
{
_loadedRoomInstance = loadedRoom;
FindLinkedPortalInRoom(loadedRoom);
}
}
/// <summary>
/// Searches for the linked portal within a loaded room.
/// First tries to find by portal ID, then falls back to path-based search.
/// </summary>
private void FindLinkedPortalInRoom(GameObject room)
{
Portal foundPortal = null;
// Try to find portal by ID first
if (!string.IsNullOrEmpty(_linkedPortalId))
{
Portal[] portalsInRoom = room.GetComponentsInChildren<Portal>();
foreach (Portal portal in portalsInRoom)
{
if (portal._portalId == _linkedPortalId)
{
foundPortal = portal;
break;
}
}
if (foundPortal == null)
{
Debug.LogWarning($"[{name}] Could not find portal with ID '{_linkedPortalId}' in room '{room.name}'. Falling back to path search.");
}
}
// Fallback to path-based search
if (foundPortal == null && !string.IsNullOrEmpty(_linkedPortalPathInRoom))
{
Transform portalTransform = room.transform.Find(_linkedPortalPathInRoom);
if (portalTransform != null)
{
foundPortal = portalTransform.GetComponent<Portal>();
}
}
// Establish bidirectional link
if (foundPortal != null)
{
_linkedPortal = foundPortal;
_linkedPortal.SetLinkedPortal(this);
if (_viewTexture != null && _linkedPortal._screen != null)
{
_linkedPortal._screen.material.SetTexture(PortalConstants.SHADER_PROPERTY_MAIN_TEXTURE, _viewTexture);
}
}
else
{
Debug.LogError($"[{name}] Could not find any portal to link in room '{room.name}'!");
}
}
/// <summary>
/// Tracks all travellers in the portal and teleports them when they cross the portal plane.
///
/// How crossing detection works:
/// 1. Calculate which side of the portal the traveller is on using dot product
/// 2. Compare with previous frame's side
/// 3. If side changed = traveller crossed the portal plane
/// 4. Transform traveller's position/rotation through portal transformation matrix
/// 5. Teleport to the linked portal
/// </summary>
private void HandleTravellers()
{
if (_linkedPortal == null)
{
return;
}
for (int i = 0; i < _trackedTravellers.Count; i++)
{
PortalTraveller traveller = _trackedTravellers[i];
Transform travellerT = traveller.transform;
// Calculate transformation matrix: from this portal's space to linked portal's space
var m = _linkedPortal.transform.localToWorldMatrix * transform.worldToLocalMatrix * travellerT.localToWorldMatrix;
// Determine which side of portal the traveller is on
Vector3 offsetFromPortal = travellerT.position - transform.position;
int portalSide = Math.Sign(Vector3.Dot(offsetFromPortal, transform.forward));
int portalSideOld = Math.Sign(Vector3.Dot(traveller.PreviousOffsetFromPortal, transform.forward));
// If side changed, traveller has crossed the portal
if (portalSide != portalSideOld)
{
if (traveller.CompareTag("Player"))
{
_playerIsTeleporting = true;
_linkedPortal._playerIsTeleporting = true;
}
// Hide both original and clone during teleport
traveller.SetGraphicsVisibility(false);
traveller.SetCloneVisibility(false);
// Teleport with transformed position and rotation
traveller.Teleport(transform, _linkedPortal.transform, m.GetColumn(3), m.rotation);
Physics.SyncTransforms();
// Show traveller at new location
traveller.SetGraphicsVisibility(true);
// Transfer tracking to the linked portal
_linkedPortal.OnTravellerEnterPortal(traveller);
_trackedTravellers.RemoveAt(i);
i--;
}
else
{
// Update clone position to match transformed position
traveller.GraphicsClone.transform.SetPositionAndRotation(m.GetColumn(3), m.rotation);
traveller.PreviousOffsetFromPortal = offsetFromPortal;
}
}
}
/// <summary>
/// Called before rendering. Updates shader slicing parameters for all tracked travellers.
/// </summary>
public void PrePortalRender()
{
if (_linkedPortal == null)
return;
foreach (var traveller in _trackedTravellers)
{
UpdateSliceParams(traveller);
}
}
/// <summary>
/// Main portal rendering function.
/// Creates a virtual camera at the linked portal's position and renders the view to a texture.
/// Supports recursive rendering (portal looking at portal looking at portal...).
/// </summary>
public void Render()
{
// Prevent rendering same portal multiple times per frame
if (_lastRenderedFrame == Time.frameCount)
return;
_lastRenderedFrame = Time.frameCount;
// Special case: player is teleporting, render without screen
if (_playerIsTeleporting)
{
_screen.enabled = false;
_portalCam.Render();
_screen.enabled = true;
_playerIsTeleporting = false;
return;
}
if (_linkedPortal == null)
{
if (_roomPrefabToLoad != null)
{
// Silently skip rendering while waiting for room to load
return;
}
}
if (_linkedPortal == null || _linkedPortal._screen == null)
{
if (_roomPrefabToLoad != null)
{
// Silently skip rendering while waiting for linked portal
return;
}
Debug.LogError($"[{name}] Cannot render: linked portal is null or has NULL _screen!");
return;
}
float distanceToCamera = Vector3.Distance(_playerCam.transform.position, transform.position);
Vector3 offsetFromPortal = _playerCam.transform.position - transform.position;
float dotToPortal = Vector3.Dot(offsetFromPortal.normalized, transform.forward);
// Don't render screen when player is crossing through
bool playerCrossingPortal = distanceToCamera < 0.5f && dotToPortal > 0.5f;
if (playerCrossingPortal)
{
_screen.enabled = false;
return;
}
// Don't render during transition to avoid flickering
if (distanceToCamera < _transitionDistance && dotToPortal > 0.5f)
{
return;
}
// Frustum culling: don't render if portal screen not visible
if (!PortalCameraUtility.VisibleFromCamera(_linkedPortal._screen, _playerCam))
{
return;
}
CreateViewTexture();
if (!_viewTexture.IsCreated())
{
_viewTexture.Create();
}
// Calculate recursive portal positions (for portal-in-portal effect)
var localToWorldMatrix = _playerCam.transform.localToWorldMatrix;
var renderPositions = new Vector3[_recursionLimit];
var renderRotations = new Quaternion[_recursionLimit];
int startIndex = 0;
_portalCam.projectionMatrix = _playerCam.projectionMatrix;
// Calculate transform matrices for each recursion level
for (int i = 0; i < _recursionLimit; i++)
{
if (i > 0)
{
// Stop recursion if screens don't overlap
if (!PortalCameraUtility.BoundsOverlap(_screenMeshFilter, _linkedPortal._screenMeshFilter, _portalCam))
break;
}
// Transform camera through portal pair
localToWorldMatrix = transform.localToWorldMatrix * _linkedPortal.transform.worldToLocalMatrix * localToWorldMatrix;
int renderOrderIndex = _recursionLimit - i - 1;
renderPositions[renderOrderIndex] = localToWorldMatrix.GetColumn(3);
renderRotations[renderOrderIndex] = localToWorldMatrix.rotation;
_portalCam.transform.SetPositionAndRotation(renderPositions[renderOrderIndex], renderRotations[renderOrderIndex]);
startIndex = renderOrderIndex;
}
_screen.enabled = false; // Hide current portal screen so camera can see through
// Render from each recursion depth
for (int i = startIndex; i < _recursionLimit; i++)
{
_portalCam.transform.SetPositionAndRotation(renderPositions[i], renderRotations[i]);
SetNearClipPlane();
HandleClipping();
_linkedPortal._screen.enabled = false;
_portalCam.Render();
_linkedPortal._screen.enabled = true;
}
_screen.enabled = true;
_linkedPortal._screen.material.mainTexture = _viewTexture;
}
/// <summary>
/// Handles shader-based clipping for travellers during rendering.
/// Determines which parts of original and clone should be visible based on portal positions.
/// Prevents visual artifacts when objects cross portal boundaries.
/// </summary>
private void HandleClipping()
{
const float hideDst = -1000;
const float showDst = 1000;
float screenThickness = _linkedPortal.ProtectScreenFromClipping(_portalCam.transform.position);
// Handle clipping for this portal's travellers
foreach (var traveller in _trackedTravellers)
{
// Original: hide if on same side as portal camera
if (SameSideOfPortal(traveller.transform.position, PortalCamPos))
traveller.SetSliceOffsetDst(hideDst, false);
else
traveller.SetSliceOffsetDst(showDst, false);
// Clone: adjust based on linked portal position
int cloneSideOfLinkedPortal = -SideOfPortal(traveller.transform.position);
bool camSameSideAsClone = _linkedPortal.SideOfPortal(PortalCamPos) == cloneSideOfLinkedPortal;
if (camSameSideAsClone)
traveller.SetSliceOffsetDst(screenThickness, true);
else
traveller.SetSliceOffsetDst(-screenThickness, true);
}
// Handle clipping for linked portal's travellers
foreach (var linkedTraveller in _linkedPortal._trackedTravellers)
{
var travellerPos = linkedTraveller.GraphicsObject.transform.position;
bool cloneOnSameSideAsCam = _linkedPortal.SideOfPortal(travellerPos) != SideOfPortal(PortalCamPos);
if (cloneOnSameSideAsCam)
linkedTraveller.SetSliceOffsetDst(hideDst, true);
else
linkedTraveller.SetSliceOffsetDst(showDst, true);
bool camSameSideAsTraveller = _linkedPortal.SameSideOfPortal(linkedTraveller.transform.position, PortalCamPos);
if (camSameSideAsTraveller)
linkedTraveller.SetSliceOffsetDst(screenThickness, false);
else
linkedTraveller.SetSliceOffsetDst(-screenThickness, false);
}
}
/// <summary>
/// Called after all portals have rendered. Resets clipping parameters.
/// </summary>
public void PostPortalRender()
{
foreach (var traveller in _trackedTravellers)
UpdateSliceParams(traveller);
ProtectScreenFromClipping(_portalCam.transform.position);
}
/// <summary>
/// Creates or recreates the render texture used to display the portal view.
/// Automatically adjusts to screen resolution changes.
/// </summary>
private void CreateViewTexture()
{
if (_viewTexture == null || _viewTexture.width != Screen.width || _viewTexture.height != Screen.height)
{
if (_viewTexture != null)
_viewTexture.Release();
_viewTexture = new RenderTexture(Screen.width, Screen.height, PortalConstants.RENDER_TEXTURE_DEPTH, PortalConstants.RENDER_TEXTURE_FORMAT);
_viewTexture.filterMode = FilterMode.Bilinear;
_viewTexture.antiAliasing = 1;
_viewTexture.Create();
_portalCam.targetTexture = _viewTexture;
_linkedPortal._screen.material.mainTexture = _viewTexture;
_linkedPortal._screen.material.SetInt(PortalConstants.SHADER_PROPERTY_DISPLAY_MASK, 1);
}
}
/// <summary>
/// Adjusts the portal screen's thickness to prevent camera near-clip plane from cutting through it.
/// The thickness is calculated based on the camera's near clip distance and FOV.
/// Returns the calculated thickness for use in clipping calculations.
/// </summary>
private float ProtectScreenFromClipping(Vector3 viewPoint)
{
float halfHeight = _playerCam.nearClipPlane * Mathf.Tan(_playerCam.fieldOfView * 0.5f * Mathf.Deg2Rad);
float halfWidth = halfHeight * _playerCam.aspect;
float dstToNearClipPlaneCorner = new Vector3(halfWidth, halfHeight, _playerCam.nearClipPlane).magnitude;
float screenThickness = dstToNearClipPlaneCorner;
Transform screenT = _screen.transform;
bool camFacingSameDirAsPortal = Vector3.Dot(transform.forward, transform.position - viewPoint) > 0;
screenT.localScale = new Vector3(screenT.localScale.x, screenT.localScale.y, screenThickness);
return screenThickness;
}
/// <summary>
/// Updates shader parameters for slicing a traveller's graphics at the portal plane.
/// Sets the slice position, normal, and offset for both original and clone materials.
/// This creates the effect of objects being "cut" by the portal surface.
/// </summary>
private void UpdateSliceParams(PortalTraveller traveller)
{
int side = SideOfPortal(traveller.transform.position);
Vector3 sliceNormal = transform.forward * -side;
Vector3 cloneSliceNormal = _linkedPortal.transform.forward * side;
Vector3 slicePos = transform.position;
Vector3 cloneSlicePos = _linkedPortal.transform.position;
float sliceOffsetDst = 0;
float cloneSliceOffsetDst = 0;
float screenThickness = _screen.transform.localScale.z;
// Adjust slicing based on player and traveller relative positions
bool playerSameSideAsTraveller = SameSideOfPortal(_playerCam.transform.position, traveller.transform.position);
if (!playerSameSideAsTraveller)
sliceOffsetDst = -screenThickness;
bool playerSameSideAsCloneAppearing = side != _linkedPortal.SideOfPortal(_playerCam.transform.position);
if (!playerSameSideAsCloneAppearing)
cloneSliceOffsetDst = -screenThickness;
// Apply slicing parameters to all materials
for (int i = 0; i < traveller.OriginalMaterials.Length; i++)
{
traveller.OriginalMaterials[i].SetVector(PortalConstants.SHADER_PROPERTY_SLICE_CENTER, slicePos);
traveller.OriginalMaterials[i].SetVector(PortalConstants.SHADER_PROPERTY_SLICE_NORMAL, sliceNormal);
traveller.OriginalMaterials[i].SetFloat(PortalConstants.SHADER_PROPERTY_SLICE_OFFSET, sliceOffsetDst);
traveller.CloneMaterials[i].SetVector(PortalConstants.SHADER_PROPERTY_SLICE_CENTER, cloneSlicePos);
traveller.CloneMaterials[i].SetVector(PortalConstants.SHADER_PROPERTY_SLICE_NORMAL, cloneSliceNormal);
traveller.CloneMaterials[i].SetFloat(PortalConstants.SHADER_PROPERTY_SLICE_OFFSET, cloneSliceOffsetDst);
}
}
/// <summary>
/// Sets up an oblique projection matrix to use the portal surface as the near clip plane.
/// This prevents objects between the camera and portal from being rendered.
/// Uses camera-space calculations to create a custom projection matrix.
/// </summary>
private void SetNearClipPlane()
{
Transform clipPlane = transform;
int dot = Math.Sign(Vector3.Dot(clipPlane.forward, transform.position - _portalCam.transform.position));
// Transform portal plane to camera space
Vector3 camSpacePos = _portalCam.worldToCameraMatrix.MultiplyPoint(clipPlane.position);
Vector3 camSpaceNormal = _portalCam.worldToCameraMatrix.MultiplyVector(clipPlane.forward) * dot;
float camSpaceDst = -Vector3.Dot(camSpacePos, camSpaceNormal) + _nearClipOffset;
// Only use oblique projection if far enough from portal
if (Mathf.Abs(camSpaceDst) > _nearClipLimit)
{
Vector4 clipPlaneCameraSpace = new Vector4(camSpaceNormal.x, camSpaceNormal.y, camSpaceNormal.z, camSpaceDst);
_portalCam.projectionMatrix = _playerCam.CalculateObliqueMatrix(clipPlaneCameraSpace);
}
else
_portalCam.projectionMatrix = _playerCam.projectionMatrix;
}
/// <summary>
/// Called when a traveller enters this portal's trigger zone.
/// Initializes tracking and creates the visual clone.
/// </summary>
private void OnTravellerEnterPortal(PortalTraveller traveller)
{
if (!_trackedTravellers.Contains(traveller))
{
traveller.EnterPortalThreshold();
traveller.PreviousOffsetFromPortal = traveller.transform.position - transform.position;
_trackedTravellers.Add(traveller);
}
}
/// <summary>
/// Unity physics callback when object enters portal's trigger collider.
/// </summary>
private void OnTriggerEnter(Collider other)
{
var traveller = other.GetComponent<PortalTraveller>();
if (traveller)
OnTravellerEnterPortal(traveller);
}
/// <summary>
/// Unity physics callback when object exits portal's trigger collider.
/// Cleans up the clone if traveller exits without teleporting.
/// </summary>
private void OnTriggerExit(Collider other)
{
var traveller = other.GetComponent<PortalTraveller>();
if (traveller && _trackedTravellers.Contains(traveller))
{
traveller.ExitPortalThreshold();
_trackedTravellers.Remove(traveller);
}
}
public void SetLinkedPortal(Portal portal)
{
_linkedPortal = portal;
}
public GameObject GetRoomPrefab() => _roomPrefabToLoad;
public string GetRoomId() => _roomId;
public string GetLinkedPortalPath() => _linkedPortalPathInRoom;
/// <summary>
/// Returns the world position where the room should be spawned.
/// Uses room spawn point if set, otherwise calculates based on portal position.
/// </summary>
public Vector3 GetRoomSpawnPosition()
{
if (_roomSpawnPoint != null)
return _roomSpawnPoint.position;
return transform.position + (transform.up + transform.right).normalized * _loadRoomDistance;
}
/// <summary>
/// Returns the rotation for spawning the room.
/// </summary>
public Quaternion GetRoomSpawnRotation()
{
if (_roomSpawnPoint != null)
return _roomSpawnPoint.rotation;
return transform.rotation;
}
/// <summary>
/// Called by PortalRoomManager when the associated room is loaded.
/// </summary>
public void OnRoomLoaded(GameObject room)
{
_loadedRoomInstance = room;
RoomLoaded?.Invoke();
}
/// <summary>
/// Called by PortalRoomManager when the associated room is unloaded.
/// Clears the linked portal reference.
/// </summary>
public void OnRoomUnloaded()
{
RoomUnloaded?.Invoke();
_loadedRoomInstance = null;
if (_roomPrefabToLoad != null)
_linkedPortal = null;
}
/// <summary>
/// Determines which side of the portal a position is on.
/// Returns +1 if in front (same direction as portal forward), -1 if behind.
/// Uses dot product to compare position relative to portal plane.
/// </summary>
public int SideOfPortal(Vector3 pos)
{
return Math.Sign(Vector3.Dot(pos - transform.position, transform.forward));
}
/// <summary>
/// Checks if two positions are on the same side of the portal.
/// </summary>
public bool SameSideOfPortal(Vector3 posA, Vector3 posB)
{
return SideOfPortal(posA) == SideOfPortal(posB);
}
public Portal GetLinkedPortal() => _linkedPortal;
public string GetPortalId() => _portalId;
public string GetLinkedPortalId() => _linkedPortalId;
public PortalDoor GetDoor() => _door;
public float GetRoomLoadDistance() => _loadRoomDistance;
private Vector3 PortalCamPos => _portalCam.transform.position;
/// <summary>
/// Unity editor callback to maintain bidirectional portal links in the inspector.
/// </summary>
private void OnValidate()
{
if (_linkedPortal != null)
_linkedPortal._linkedPortal = this;
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 5bee016a7303e2c458585f5cc66817e2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 200
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,307 +0,0 @@
using UnityEngine;
using System.Collections.Generic;
using Unheimlich.Features.Portals;
using Unheimlich.Core.Utils;
using Unheimlich.Dev;
namespace Unheimlich.Features.Portals
{
/// <summary>
/// Creates virtual audio proxies for sounds coming from the other side of a portal.
/// When a portal links to a room, sounds from that room are projected through the portal
/// so the player hears them spatially from the portal's location. This creates seamless
/// audio transitions when walking through portals.
/// </summary>
public class PortalAudioProxy : MonoBehaviour
{
[Header("Settings")]
[SerializeField] private float _maxDistance = 20f;
[SerializeField] private float _fadeStartDistance = 15f;
[SerializeField] private float _transitionDistance = 2f;
[SerializeField] private bool _useLowPassFilter = true;
[SerializeField] private float _lowPassCutoff = 5000f;
[SerializeField] private float _timeSyncThreshold = 0.1f;
[SerializeField] private float _minSpread = 60f;
[SerializeField] private float _maxSpread = 180f;
// [SerializeField] private float _volumeRolloffPower = 2f;
[Header("Debug")]
[SerializeField] private List<AudioSource> _remoteSources = new List<AudioSource>();
private Portal _portal;
private Dictionary<AudioSource, AudioSource> _proxyMapping = new Dictionary<AudioSource, AudioSource>();
private Dictionary<AudioSource, AudioLowPassFilter> _filterMapping = new Dictionary<AudioSource, AudioLowPassFilter>();
private Camera _playerCamera;
private GameObject _lastLoadedRoomInstance;
private bool _needsRefresh;
private void Awake()
{
_portal = GetComponent<Portal>();
_playerCamera = Camera.main;
}
private void OnEnable()
{
if (_portal != null)
{
_portal.RoomLoaded += OnRoomLoaded;
_portal.RoomUnloaded += OnRoomUnloaded;
}
}
private void OnDisable()
{
if (_portal != null)
{
_portal.RoomLoaded -= OnRoomLoaded;
_portal.RoomUnloaded -= OnRoomUnloaded;
}
}
private void OnRoomLoaded()
{
_needsRefresh = true;
}
private void OnRoomUnloaded()
{
_needsRefresh = true;
}
/// <summary>
/// Main update loop: refreshes audio sources when the room changes,
/// then updates all proxy positions and volumes each frame.
/// </summary>
private void Update()
{
if (_portal == null || _portal.LinkedPortal == null)
return;
GameObject currentLoadedRoom = _portal.LoadedRoomInstance;
if (currentLoadedRoom != _lastLoadedRoomInstance)
{
_lastLoadedRoomInstance = currentLoadedRoom;
_needsRefresh = true;
}
if (_needsRefresh)
{
RefreshRemoteSources();
_needsRefresh = false;
}
UpdateProxies();
}
/// <summary>
/// Scans the loaded room for all AudioSource components and caches them.
/// Called whenever a new room is loaded through the portal.
/// </summary>
private void RefreshRemoteSources()
{
GameObject linkedRoom = _portal.LoadedRoomInstance;
if (linkedRoom == null)
{
_remoteSources.Clear();
return;
}
AudioSource[] sources = linkedRoom.GetComponentsInChildren<AudioSource>();
_remoteSources.Clear();
_remoteSources.AddRange(sources);
}
/// <summary>
/// Updates all audio proxies each frame:
/// - Syncs playback state with remote sources
/// - Positions proxies at the portal location
/// - Adjusts volume based on distance and player orientation
/// - Applies low-pass filtering for sounds through the portal
/// This creates the illusion that sounds are coming from the portal opening.
/// </summary>
private void UpdateProxies()
{
Camera playerCam = _playerCamera;
if (playerCam == null)
return;
Portal linkedPortal = _portal.LinkedPortal;
if (linkedPortal == null)
return;
Vector3 playerPos = playerCam.transform.position;
Vector3 portalAPos = transform.position;
float distPlayerToPortalA = Vector3.Distance(playerPos, portalAPos);
Vector3 offsetFromPortalA = playerPos - portalAPos;
float dotToPortalA = Vector3.Dot(offsetFromPortalA.normalized, transform.forward);
bool playerFacingPortal = dotToPortalA > 0;
float transitionBlend = 0f;
if (playerFacingPortal && distPlayerToPortalA < _transitionDistance)
{
transitionBlend = 1f - (distPlayerToPortalA / _transitionDistance);
}
List<AudioSource> toRemove = new List<AudioSource>();
foreach (var remoteSource in _remoteSources)
{
if (remoteSource == null)
{
toRemove.Add(remoteSource);
continue;
}
if (!remoteSource.isPlaying)
{
if (_proxyMapping.ContainsKey(remoteSource))
{
AudioSource proxy = _proxyMapping[remoteSource];
if (proxy != null && proxy.isPlaying)
proxy.Stop();
}
continue;
}
AudioSource localProxy = GetOrCreateProxy(remoteSource);
if (localProxy.clip != remoteSource.clip)
{
localProxy.clip = remoteSource.clip;
localProxy.loop = remoteSource.loop;
localProxy.time = remoteSource.time;
localProxy.Play();
}
else if (!localProxy.isPlaying)
{
localProxy.time = remoteSource.time;
localProxy.Play();
}
else
{
float timeDifference = Mathf.Abs(localProxy.time - remoteSource.time);
if (timeDifference > _timeSyncThreshold)
localProxy.time = remoteSource.time;
}
localProxy.pitch = remoteSource.pitch;
if (_useLowPassFilter && _filterMapping.ContainsKey(remoteSource))
{
AudioLowPassFilter filter = _filterMapping[remoteSource];
if (filter != null)
filter.enabled = !_portal.IsDoorOpen;
}
Vector3 virtualPlayerPos = linkedPortal.transform.TransformPoint(transform.InverseTransformPoint(playerPos));
Vector3 proxyWorldPos = transform.TransformPoint(linkedPortal.transform.InverseTransformPoint(remoteSource.transform.position));
localProxy.transform.position = proxyWorldPos;
float baseVolume = remoteSource.volume;
AudioSourceDemo demo = remoteSource.GetComponent<AudioSourceDemo>();
if (demo != null)
baseVolume = demo.OriginalVolume;
localProxy.volume = baseVolume * (1f - transitionBlend);
if (demo != null)
{
float sourceFade = demo.GetDistanceFadeMultiplier(virtualPlayerPos);
localProxy.volume *= sourceFade;
}
float distanceToPortal = Vector3.Distance(playerPos, portalAPos);
if (distanceToPortal > _fadeStartDistance)
{
float fadeRange = _maxDistance - _fadeStartDistance;
float fadeAmount = (distanceToPortal - _fadeStartDistance) / fadeRange;
fadeAmount = Mathf.Clamp01(fadeAmount);
localProxy.volume *= (1f - fadeAmount);
}
if (localProxy.volume <= PortalConstants.MIN_VOLUME_THRESHOLD && localProxy.isPlaying)
{
localProxy.Stop();
continue;
}
localProxy.spread = Mathf.Lerp(_minSpread, _maxSpread, transitionBlend);
}
foreach (var source in toRemove)
{
if (_proxyMapping.ContainsKey(source))
{
AudioSource proxy = _proxyMapping[source];
if (proxy != null)
Destroy(proxy.gameObject);
_proxyMapping.Remove(source);
}
if (_filterMapping.ContainsKey(source))
_filterMapping.Remove(source);
_remoteSources.Remove(source);
}
}
private AudioSource GetOrCreateProxy(AudioSource remoteSource)
{
if (_proxyMapping.ContainsKey(remoteSource) && _proxyMapping[remoteSource] != null)
return _proxyMapping[remoteSource];
GameObject proxyObj = new GameObject($"AudioProxy_{remoteSource.name}");
proxyObj.transform.SetParent(transform);
proxyObj.transform.localPosition = Vector3.zero;
AudioSource proxy = proxyObj.AddComponent<AudioSource>();
proxy.spatialBlend = 1f;
proxy.spatialize = true;
proxy.spatializePostEffects = true;
proxy.reverbZoneMix = 1f;
proxy.spread = 60f;
proxy.rolloffMode = remoteSource.rolloffMode;
proxy.minDistance = remoteSource.minDistance;
proxy.maxDistance = remoteSource.maxDistance;
proxy.playOnAwake = false;
if (_useLowPassFilter)
{
AudioLowPassFilter filter = proxyObj.AddComponent<AudioLowPassFilter>();
filter.cutoffFrequency = _lowPassCutoff;
filter.enabled = false;
_filterMapping[remoteSource] = filter;
}
_proxyMapping[remoteSource] = proxy;
return proxy;
}
private void OnDestroy()
{
foreach (var kvp in _proxyMapping)
{
if (kvp.Value != null)
Destroy(kvp.Value.gameObject);
}
_proxyMapping.Clear();
_filterMapping.Clear();
}
private void OnDrawGizmosSelected()
{
Gizmos.color = Color.cyan;
Gizmos.DrawWireSphere(transform.position, _maxDistance);
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 1ef7477556cb1fa45ab6cf4a9422c405
@@ -1,45 +0,0 @@
using UnityEngine;
using Unheimlich.World.Environment;
namespace Unheimlich.Features.Portals
{
/// <summary>
/// A door that is synchronized between linked portals.
/// When opened/closed, it automatically syncs the state with the door on the other side.
/// </summary>
public class PortalDoor : DoorInteractable
{
private Portal _parentPortal;
protected override void Start()
{
base.Start();
_parentPortal = GetComponentInParent<Portal>();
}
/// <summary>
/// Handles interaction with the door.
/// Toggles both this door and the linked portal's door simultaneously.
/// </summary>
public override void Interact()
{
SyncToggle();
}
/// <summary>
/// Toggles this door and synchronizes the state with the linked portal's door.
/// Ensures both doors open/close together for a consistent experience.
/// </summary>
private void SyncToggle()
{
ToggleDoor();
if (_parentPortal != null && _parentPortal.GetLinkedPortal() != null)
{
PortalDoor linkedDoor = _parentPortal.GetLinkedPortal().GetDoor();
if (linkedDoor != null)
linkedDoor.ToggleDoor();
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: f96b3c121a38b8e4784628da571e6c5b
@@ -1,67 +0,0 @@
using UnityEngine;
using UnityEngine.Rendering;
namespace Unheimlich.Features.Portals
{
/// <summary>
/// Main orchestrator for the portal rendering system.
/// Attach this to the main camera to enable seamless portal rendering.
/// Manages the render pipeline for all active portals in the scene.
///
/// URP-compatible version using RenderPipelineManager callbacks.
/// </summary>
public class PortalMainCamera : MonoBehaviour
{
private Camera _mainCamera;
private void Awake()
{
_mainCamera = GetComponent<Camera>();
}
private void OnEnable()
{
RenderPipelineManager.beginCameraRendering += OnBeginCameraRendering;
}
private void OnDisable()
{
RenderPipelineManager.beginCameraRendering -= OnBeginCameraRendering;
}
/// <summary>
/// Called before each camera renders in URP pipeline.
/// This is where we inject the portal rendering logic before the main camera renders.
/// </summary>
private void OnBeginCameraRendering(ScriptableRenderContext context, Camera camera)
{
// Only render portals for the main camera, not for portal cameras themselves
if (camera != _mainCamera)
return;
// Phase 1: Prepare all portals for rendering (update shader clipping parameters)
for (int i = 0; i < Portal.AllPortals.Count; i++)
{
var p = Portal.AllPortals[i];
if (p != null && p.isActiveAndEnabled)
p.PrePortalRender();
}
// Phase 2: Render all portals (create the view through each portal)
for (int i = 0; i < Portal.AllPortals.Count; i++)
{
var p = Portal.AllPortals[i];
if (p != null && p.isActiveAndEnabled)
p.Render();
}
// Phase 3: Clean up after portal rendering
for (int i = 0; i < Portal.AllPortals.Count; i++)
{
var p = Portal.AllPortals[i];
if (p != null && p.isActiveAndEnabled)
p.PostPortalRender();
}
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 98eb193612a097646bb0d333bb4aae51
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 300
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,288 +0,0 @@
using System.Collections.Generic;
using UnityEngine;
using Unheimlich.Core;
using Unheimlich.Features.Portals;
namespace Unheimlich.Features.Portals
{
/// <summary>
/// Manages dynamic loading and unloading of rooms connected by portals.
/// Monitors player distance to portals and loads/unloads room instances accordingly.
/// Supports room pooling (keeping inactive rooms in memory) and distance-based activation.
/// </summary>
public class PortalRoomManager : Singleton<PortalRoomManager>
{
[Header("Settings")]
[SerializeField] private float _loadDistance = 15f;
[Tooltip("Distance at which a loaded room gets deactivated (must be > load distance to avoid flip-flop)")]
[SerializeField] private float _deactivateDistance = 50f;
[Tooltip("Distance at which a deactivated room gets reactivated (should be < deactivate distance)")]
[SerializeField] private float _reactivateDistance = 30f;
[SerializeField] private bool _keepRoomsInMemory = true;
[Header("Distance Calculation")]
[Tooltip("If true, calculates distance from player to actual room position (portal + loadRoomDistance)")]
[SerializeField] private bool _useRealRoomDistance = true;
private Transform _player;
private Dictionary<string, LoadedRoom> _loadedRooms = new Dictionary<string, LoadedRoom>();
private List<Portal> _registeredPortals = new List<Portal>();
private GameObject _currentPlayerRoom = null;
/// <summary>
/// Represents a room instance that has been loaded through a portal.
/// Caches room bounds and colliders for efficient distance/containment calculations.
/// </summary>
private class LoadedRoom
{
public GameObject RoomInstance;
public string RoomId;
public Vector3 LoadPosition;
public Portal SourcePortal;
public Bounds CachedBounds;
public bool BoundsCalculated = false;
public Collider[] CachedColliders;
}
private void Start()
{
_player = GameObject.FindGameObjectWithTag("Player")?.transform;
if (_player == null)
Debug.LogWarning("Player not found! Make sure your player has the 'Player' tag.");
}
private void Update()
{
if (_player == null)
return;
CheckPortalsForLoading();
CheckRoomsForUnloading();
}
public void RegisterPortal(Portal portal)
{
if (!_registeredPortals.Contains(portal))
_registeredPortals.Add(portal);
}
public void UnregisterPortal(Portal portal)
{
if (_registeredPortals.Contains(portal))
_registeredPortals.Remove(portal);
}
public GameObject GetLoadedRoom(string roomId)
{
if (_loadedRooms.TryGetValue(roomId, out LoadedRoom loadedRoom))
return loadedRoom.RoomInstance;
return null;
}
/// <summary>
/// Checks all registered portals and loads rooms for portals within range.
/// Optionally accounts for the portal's configured room spawn distance.
/// </summary>
private void CheckPortalsForLoading()
{
foreach (Portal portal in _registeredPortals)
{
float distanceToPortal = Vector3.Distance(_player.position, portal.transform.position);
float effectiveLoadDistance = _loadDistance;
if (_useRealRoomDistance)
effectiveLoadDistance += portal.GetRoomLoadDistance();
if (distanceToPortal <= effectiveLoadDistance)
LoadRoom(portal);
}
}
/// <summary>
/// Checks all loaded rooms and determines if they should be deactivated, reactivated, or unloaded
/// based on distance thresholds. Prevents unloading the room the player is currently inside.
/// </summary>
private void CheckRoomsForUnloading()
{
List<string> roomsToUnload = new List<string>();
UpdateCurrentPlayerRoom();
foreach (var kvp in _loadedRooms)
{
LoadedRoom loadedRoom = kvp.Value;
if (loadedRoom.RoomInstance == null)
{
roomsToUnload.Add(kvp.Key);
continue;
}
if (loadedRoom.RoomInstance == _currentPlayerRoom)
continue;
float distanceToPortal = GetDistanceToRoom(loadedRoom);
float effectiveDeactivateDistance = _deactivateDistance;
float effectiveReactivateDistance = _reactivateDistance;
if (_useRealRoomDistance)
{
float roomOffset = loadedRoom.SourcePortal.GetRoomLoadDistance();
effectiveDeactivateDistance += roomOffset;
effectiveReactivateDistance += roomOffset;
}
if (distanceToPortal > effectiveDeactivateDistance)
{
if (loadedRoom.RoomInstance.activeSelf)
roomsToUnload.Add(kvp.Key);
}
else if (distanceToPortal <= effectiveReactivateDistance)
{
if (!loadedRoom.RoomInstance.activeSelf)
loadedRoom.RoomInstance.SetActive(true);
}
}
foreach (string roomId in roomsToUnload)
{
UnloadRoom(roomId);
}
}
/// <summary>
/// Calculates the distance from the player to a loaded room.
/// Lazily calculates and caches room bounds on first call for performance.
/// </summary>
private float GetDistanceToRoom(LoadedRoom loadedRoom)
{
if (!loadedRoom.BoundsCalculated)
{
loadedRoom.CachedBounds = CalculateRoomBounds(loadedRoom.RoomInstance);
loadedRoom.BoundsCalculated = true;
}
return Vector3.Distance(_player.position, loadedRoom.CachedBounds.center);
}
/// <summary>
/// Calculates the combined bounding box of all renderers in a room.
/// Used to determine room center for distance calculations.
/// </summary>
private Bounds CalculateRoomBounds(GameObject room)
{
Renderer[] renderers = room.GetComponentsInChildren<Renderer>();
if (renderers.Length == 0)
return new Bounds(room.transform.position, Vector3.one);
Bounds bounds = renderers[0].bounds;
for (int i = 1; i < renderers.Length; i++)
bounds.Encapsulate(renderers[i].bounds);
return bounds;
}
/// <summary>
/// Determines which room (if any) currently contains the player.
/// Uses cached collider bounds to detect room occupancy.
/// </summary>
private void UpdateCurrentPlayerRoom()
{
_currentPlayerRoom = null;
foreach (var kvp in _loadedRooms)
{
LoadedRoom loadedRoom = kvp.Value;
if (loadedRoom.RoomInstance == null)
continue;
Collider[] roomColliders = loadedRoom.CachedColliders;
if (roomColliders == null) continue;
foreach (Collider col in roomColliders)
{
if (col != null && col.bounds.Contains(_player.position))
{
_currentPlayerRoom = loadedRoom.RoomInstance;
return;
}
}
}
}
/// <summary>
/// Instantiates a room from a portal's prefab at the configured spawn point.
/// If the room is already loaded, reactivates it instead of creating a new instance.
/// Notifies the portal when its room is successfully loaded.
/// </summary>
private void LoadRoom(Portal portal)
{
GameObject roomPrefab = portal.GetRoomPrefab();
if (roomPrefab == null)
{
Debug.LogWarning($"[LOAD] Portal {portal.name} has no prefab!");
return;
}
string roomId = portal.GetRoomId();
if (_loadedRooms.TryGetValue(roomId, out LoadedRoom existingRoom))
{
if (existingRoom.RoomInstance != null)
existingRoom.RoomInstance.SetActive(true);
return;
}
Vector3 spawnPosition = portal.GetRoomSpawnPosition();
Quaternion spawnRotation = portal.GetRoomSpawnRotation();
GameObject roomInstance = Instantiate(roomPrefab, spawnPosition, spawnRotation);
roomInstance.name = $"{roomPrefab.name}_Loaded";
foreach (var renderer in roomInstance.GetComponentsInChildren<MeshRenderer>())
renderer.gameObject.SendMessage("OnBecameVisible", SendMessageOptions.DontRequireReceiver);
LoadedRoom loadedRoom = new LoadedRoom
{
RoomInstance = roomInstance,
RoomId = roomId,
LoadPosition = portal.transform.position,
SourcePortal = portal,
CachedColliders = roomInstance.GetComponentsInChildren<Collider>()
};
_loadedRooms.Add(roomId, loadedRoom);
portal.OnRoomLoaded(roomInstance);
}
/// <summary>
/// Deactivates or destroys a loaded room based on the _keepRoomsInMemory setting.
/// If keeping in memory, the room is deactivated but not destroyed (faster to reactivate).
/// If not keeping in memory, the room is fully destroyed and removed from tracking.
/// Notifies the source portal when its room is unloaded.
/// </summary>
private void UnloadRoom(string roomId)
{
if (_loadedRooms.TryGetValue(roomId, out LoadedRoom loadedRoom))
{
if (loadedRoom.RoomInstance != null)
{
if (loadedRoom.SourcePortal != null)
loadedRoom.SourcePortal.OnRoomUnloaded();
if (_keepRoomsInMemory)
loadedRoom.RoomInstance.SetActive(false);
else
{
Destroy(loadedRoom.RoomInstance);
_loadedRooms.Remove(roomId);
}
}
}
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 25abbf68f203db94381b5c22eb1573bc
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 100
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,148 +0,0 @@
using System.Collections.Generic;
using UnityEngine;
using Unheimlich.Core.Utils;
namespace Unheimlich.Features.Portals
{
/// <summary>
/// Component that allows an object to travel through portals.
/// Handles teleportation, visual cloning when crossing, and shader-based slicing.
/// Attach this to any object that should be able to pass through portals (player, NPCs, etc.)
/// </summary>
[RequireComponent(typeof(CharacterController))]
public class PortalTraveller : MonoBehaviour
{
/// <summary>The visual representation of this traveller (mesh renderers)</summary>
public GameObject GraphicsObject;
/// <summary>Clone created when traveller enters portal threshold</summary>
public GameObject GraphicsClone { get; set; }
/// <summary>Tracks position relative to portal from previous frame to detect crossing</summary>
public Vector3 PreviousOffsetFromPortal { get; set; }
/// <summary>Materials of the original graphics (used for shader slicing)</summary>
public Material[] OriginalMaterials { get; set; }
/// <summary>Materials of the clone (used for shader slicing)</summary>
public Material[] CloneMaterials { get; set; }
private CharacterController _characterController;
private void Awake()
{
_characterController = GetComponent<CharacterController>();
}
/// <summary>
/// Teleports this traveller through a portal.
/// Temporarily disables CharacterController to prevent physics conflicts during teleport.
/// </summary>
/// <param name="fromPortal">The portal being exited</param>
/// <param name="toPortal">The destination portal</param>
/// <param name="pos">New world position</param>
/// <param name="rot">New world rotation</param>
public virtual void Teleport(Transform fromPortal, Transform toPortal, Vector3 pos, Quaternion rot)
{
if (_characterController != null)
_characterController.enabled = false;
transform.position = pos;
transform.rotation = rot;
if (_characterController != null)
{
_characterController.enabled = true;
Physics.SyncTransforms();
}
}
/// <summary>
/// Called when the traveller enters the portal's trigger zone.
/// Creates a visual clone that will appear on the other side of the portal.
/// </summary>
public virtual void EnterPortalThreshold()
{
if (GraphicsClone == null)
{
GraphicsClone = Instantiate(GraphicsObject);
GraphicsClone.transform.parent = GraphicsObject.transform.parent;
GraphicsClone.transform.localScale = GraphicsObject.transform.localScale;
OriginalMaterials = GetMaterials(GraphicsObject);
CloneMaterials = GetMaterials(GraphicsClone);
}
else
GraphicsClone.SetActive(true);
}
/// <summary>
/// Called when the traveller exits the portal's trigger zone without teleporting.
/// Hides the clone and resets shader slicing parameters.
/// </summary>
public virtual void ExitPortalThreshold()
{
GraphicsClone.SetActive(false);
for (int i = 0; i < OriginalMaterials.Length; i++)
OriginalMaterials[i].SetVector(PortalConstants.SHADER_PROPERTY_SLICE_NORMAL, Vector3.zero);
}
/// <summary>
/// Sets the slice offset distance for shader-based clipping.
/// Used to hide/show parts of the traveller based on portal plane position.
/// </summary>
/// <param name="dst">Distance offset for slicing plane</param>
/// <param name="clone">If true, applies to clone materials; otherwise to original</param>
public void SetSliceOffsetDst(float dst, bool clone)
{
for (int i = 0; i < OriginalMaterials.Length; i++)
{
if (clone)
CloneMaterials[i].SetFloat(PortalConstants.SHADER_PROPERTY_SLICE_OFFSET, dst);
else
OriginalMaterials[i].SetFloat(PortalConstants.SHADER_PROPERTY_SLICE_OFFSET, dst);
}
}
/// <summary>
/// Collects all materials from MeshRenderers in the graphics object hierarchy.
/// These materials will be modified with shader slicing parameters.
/// </summary>
private Material[] GetMaterials(GameObject g)
{
var renderers = g.GetComponentsInChildren<MeshRenderer>();
var matList = new List<Material>();
foreach (var renderer in renderers)
{
foreach (var mat in renderer.materials)
matList.Add(mat);
}
return matList.ToArray();
}
/// <summary>
/// Shows or hides the original graphics by enabling/disabling renderers.
/// </summary>
public void SetGraphicsVisibility(bool visible)
{
var renderers = GraphicsObject.GetComponentsInChildren<Renderer>();
foreach (var renderer in renderers)
renderer.enabled = visible;
}
/// <summary>
/// Shows or hides the clone graphics by enabling/disabling renderers.
/// </summary>
public void SetCloneVisibility(bool visible)
{
if (GraphicsClone != null)
{
var renderers = GraphicsClone.GetComponentsInChildren<Renderer>();
foreach (var renderer in renderers)
renderer.enabled = visible;
}
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: e825afa0efeb69949bf3ca8b3f99d7eb
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 400
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: