Advanced POC - portals, dialogue system + floating words, room loading
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5d918f17293f2144bb062aedf3c87c64
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,52 @@
|
||||
using UnityEngine;
|
||||
using Unheimlich.Interactions.Core;
|
||||
|
||||
namespace Unheimlich.Gameplay.Environment
|
||||
{
|
||||
public class DoorInteractable : InteractableBase
|
||||
{
|
||||
[Header("Door Settings")]
|
||||
public bool IsOpen = false;
|
||||
[SerializeField] private float _openAngle = 90f;
|
||||
[SerializeField] private float _animationDuration = 0.5f;
|
||||
[SerializeField] private LeanTweenType _easeType = LeanTweenType.easeInOutQuad;
|
||||
|
||||
private Quaternion _closedRotation;
|
||||
private Quaternion _openRotation;
|
||||
|
||||
protected virtual void Start()
|
||||
{
|
||||
_closedRotation = transform.rotation;
|
||||
_openRotation = _closedRotation * Quaternion.Euler(0, _openAngle, 0);
|
||||
|
||||
InteractionText = IsOpen ? "Close" : "Open";
|
||||
}
|
||||
|
||||
public override void Interact(GameObject interactor)
|
||||
{
|
||||
ToggleDoor();
|
||||
}
|
||||
|
||||
public void ToggleDoor()
|
||||
{
|
||||
LeanTween.cancel(gameObject);
|
||||
|
||||
if (!IsOpen)
|
||||
{
|
||||
LeanTween.rotate(gameObject, _openRotation.eulerAngles, _animationDuration)
|
||||
.setEase(_easeType);
|
||||
|
||||
IsOpen = true;
|
||||
InteractionText = "Close";
|
||||
}
|
||||
else
|
||||
{
|
||||
LeanTween.rotate(gameObject, _closedRotation.eulerAngles, _animationDuration)
|
||||
.setEase(_easeType);
|
||||
|
||||
IsOpen = false;
|
||||
InteractionText = "Open";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d620ab476d55f2542a31783a6684f189
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 98264b75876e33a48a42fe7128eddc19
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,102 @@
|
||||
using UnityEngine;
|
||||
using Unheimlich.Interactions.Core;
|
||||
using Unheimlich.Interactions.FloatingWords;
|
||||
|
||||
namespace Unheimlich.Gameplay.Items
|
||||
{
|
||||
/// <summary>
|
||||
/// Interactable item that can be picked up and examined closely by the player.
|
||||
/// Temporarily moves to inspection position and allows rotation via look input.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Collider), typeof(Rigidbody))]
|
||||
public class InspectableItem : InteractableBase
|
||||
{
|
||||
[Header("Inspection Settings")]
|
||||
[SerializeField] private float _inspectionDistance = 1f;
|
||||
[SerializeField] private float _rotationSpeed = 100f;
|
||||
|
||||
[Header("Animation Settings")]
|
||||
[SerializeField] private bool _blockInteractionDuringAnimation = true;
|
||||
|
||||
private Vector3 _originalPosition;
|
||||
private Quaternion _originalRotation;
|
||||
private Transform _originalParent;
|
||||
private Rigidbody _rigidbody;
|
||||
private bool _isAnimationPlaying = false;
|
||||
private ObjectDescriptionTrigger _objectDescriptionTrigger;
|
||||
|
||||
public bool IsAnimationPlaying => _isAnimationPlaying;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
_rigidbody = GetComponent<Rigidbody>();
|
||||
_rigidbody.isKinematic = true;
|
||||
|
||||
_originalPosition = transform.position;
|
||||
_originalRotation = transform.rotation;
|
||||
_originalParent = transform.parent;
|
||||
|
||||
_objectDescriptionTrigger = GetComponent<ObjectDescriptionTrigger>();
|
||||
}
|
||||
|
||||
public override void Interact(GameObject interactor)
|
||||
{
|
||||
if (_blockInteractionDuringAnimation)
|
||||
{
|
||||
if (_isAnimationPlaying)
|
||||
return;
|
||||
|
||||
if (_objectDescriptionTrigger != null &&
|
||||
_objectDescriptionTrigger.IsInMergeOnceMode &&
|
||||
_objectDescriptionTrigger.IsAnimationActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (PlayerInspectionHandler.Instance != null)
|
||||
PlayerInspectionHandler.Instance.ToggleInspection(this);
|
||||
}
|
||||
|
||||
public void SetAnimationState(bool isPlaying)
|
||||
{
|
||||
_isAnimationPlaying = isPlaying;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prepares the item for inspection by parenting it to the hold point and making it kinematic.
|
||||
/// </summary>
|
||||
public void PrepareForInspection(Transform holdPoint)
|
||||
{
|
||||
_originalPosition = transform.position;
|
||||
_originalRotation = transform.rotation;
|
||||
_originalParent = transform.parent;
|
||||
|
||||
_rigidbody.isKinematic = true;
|
||||
transform.SetParent(holdPoint);
|
||||
transform.localPosition = Vector3.forward * _inspectionDistance;
|
||||
transform.localRotation = Quaternion.identity;
|
||||
}
|
||||
|
||||
public void Release()
|
||||
{
|
||||
transform.SetParent(_originalParent);
|
||||
transform.position = _originalPosition;
|
||||
transform.rotation = _originalRotation;
|
||||
|
||||
_rigidbody.isKinematic = true;
|
||||
}
|
||||
|
||||
public float RotationSpeed => _rotationSpeed;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
LeanTween.init();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
LeanTween.cancel(gameObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cd19509bd29a28648a08eaf7f6146ef1
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a7d7d4707026b6d40be69401e67ae554
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,78 @@
|
||||
using UnityEngine;
|
||||
using Unheimlich.Interactions.Core;
|
||||
|
||||
namespace Unheimlich.Gameplay.Player
|
||||
{
|
||||
/// <summary>
|
||||
/// Simulates camera head bobbing motion during player movement and idle states.
|
||||
/// Applies subtle oscillation based on player grounded and movement state.
|
||||
/// </summary>
|
||||
public class CameraHeadBob : MonoBehaviour
|
||||
{
|
||||
[Header("Bob Settings - Idle")]
|
||||
[SerializeField] private float _idleBobSpeed = 1f;
|
||||
[SerializeField] private float _idleBobAmplitude = 0.005f;
|
||||
|
||||
[Header("Bob Settings - Walk")]
|
||||
[SerializeField] private float _walkBobSpeed = 12f;
|
||||
[SerializeField] private float _walkBobAmplitude = 0.02f;
|
||||
[SerializeField] private float _resetSpeed = 5f;
|
||||
|
||||
private PlayerMovement _playerMovement;
|
||||
private float _timer = 0f;
|
||||
private Vector3 _startLocalPos;
|
||||
|
||||
void Start()
|
||||
{
|
||||
_playerMovement = PlayerMovement.Instance;
|
||||
_startLocalPos = transform.localPosition;
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (_playerMovement == null || _playerMovement.CharacterController == null)
|
||||
return;
|
||||
|
||||
if (PlayerInspectionHandler.Instance != null && PlayerInspectionHandler.Instance.IsInspecting)
|
||||
{
|
||||
ApplyBob(_idleBobSpeed, _idleBobAmplitude);
|
||||
return;
|
||||
}
|
||||
|
||||
bool isMoving = _playerMovement.IsMoving;
|
||||
bool isGrounded = _playerMovement.CharacterController.isGrounded;
|
||||
|
||||
if (isGrounded)
|
||||
{
|
||||
if (isMoving)
|
||||
ApplyBob(_walkBobSpeed, _walkBobAmplitude);
|
||||
else
|
||||
ApplyBob(_idleBobSpeed, _idleBobAmplitude);
|
||||
}
|
||||
else
|
||||
ResetPosition();
|
||||
}
|
||||
|
||||
private void ApplyBob(float speed, float amplitude)
|
||||
{
|
||||
_timer += Time.deltaTime * speed;
|
||||
|
||||
float x = Mathf.Sin(_timer * 0.5f) * amplitude;
|
||||
float y = Mathf.Cos(_timer) * amplitude;
|
||||
|
||||
Vector3 newPos = _startLocalPos;
|
||||
newPos.x += x;
|
||||
newPos.y += y;
|
||||
|
||||
transform.localPosition = newPos;
|
||||
}
|
||||
|
||||
private void ResetPosition()
|
||||
{
|
||||
transform.localPosition = Vector3.Lerp(transform.localPosition, _startLocalPos, Time.deltaTime * _resetSpeed);
|
||||
|
||||
if (Vector3.Distance(transform.localPosition, _startLocalPos) < 0.001f)
|
||||
_timer = 0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a2e180d338cc26242a3a9d962eea7f7c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: -200
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,22 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.Gameplay.Player
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton player reference accessible globally throughout the game.
|
||||
/// </summary>
|
||||
public class Player : MonoBehaviour
|
||||
{
|
||||
public static Player Instance { get; private set; }
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this)
|
||||
{
|
||||
Destroy(this);
|
||||
return;
|
||||
}
|
||||
Instance = this;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d47a9ebc2bd5f9345ad873a400c3d8bf
|
||||
@@ -0,0 +1,165 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using Unheimlich.Gameplay.Portals;
|
||||
using InputManager = Unheimlich.Managers.PlayerInputManager;
|
||||
|
||||
namespace Unheimlich.Gameplay.Player
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles first-person camera rotation and look mechanics for the player.
|
||||
/// Supports both mouse and gamepad input with adaptive sensitivity.
|
||||
/// Includes aim assist for dialogue choices and camera shake effects.
|
||||
/// </summary>
|
||||
public class PlayerLook : MonoBehaviour
|
||||
{
|
||||
private const float INPUT_DEADZONE = 0.1f;
|
||||
private const float MOUSE_DELTA_MULTIPLIER = 0.001f;
|
||||
|
||||
public static PlayerLook Instance { get; private set; }
|
||||
|
||||
[Header("Components")]
|
||||
[SerializeField] private Transform _playerCamera, _playerCameraPivot;
|
||||
|
||||
[Header("Settings")]
|
||||
[SerializeField] private int _frameRate = 120;
|
||||
[SerializeField] private float _mouseSensitivity = 0.2f;
|
||||
[SerializeField] private float _gamepadSensitivity = 100f;
|
||||
[SerializeField] private float _minLookAngle = -60f;
|
||||
[SerializeField] private float _maxLookAngle = 60f;
|
||||
|
||||
[Header("Aim Assist")]
|
||||
[SerializeField] private float _aimAssistStrength = 25f;
|
||||
[SerializeField] private float _aimAssistMaxAngle = 10f;
|
||||
|
||||
public bool HasActiveLookInput { get; private set; }
|
||||
public bool IsAimLocked { get; private set; } = false;
|
||||
|
||||
[Header("Shake Control")]
|
||||
[SerializeField] private float _shakeFrequency = 40f;
|
||||
private float _shakeInterval;
|
||||
private float _shakeTimer = 0f;
|
||||
|
||||
private float _horizontalRotation;
|
||||
private float _verticalRotation;
|
||||
private bool _isUsingGamepad = false;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this)
|
||||
{
|
||||
Destroy(this);
|
||||
return;
|
||||
}
|
||||
Instance = this;
|
||||
|
||||
QualitySettings.vSyncCount = 0;
|
||||
Application.targetFrameRate = _frameRate;
|
||||
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
Cursor.lockState = CursorLockMode.Locked;
|
||||
Cursor.visible = false;
|
||||
_shakeInterval = 1f / _shakeFrequency;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if(!InputManager.IsLookingEnabled)
|
||||
return;
|
||||
|
||||
HandleLook();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes look input from mouse or gamepad and updates camera rotation.
|
||||
/// Automatically detects input device and applies appropriate sensitivity.
|
||||
/// Clamps vertical rotation to prevent over-rotation.
|
||||
/// </summary>
|
||||
private void HandleLook()
|
||||
{
|
||||
Vector2 lookInput = InputManager.Instance.LookInput;
|
||||
|
||||
HasActiveLookInput = lookInput.magnitude > INPUT_DEADZONE;
|
||||
|
||||
if (Gamepad.current != null && Gamepad.current.rightStick.ReadValue().magnitude > PortalConstants.GAMEPAD_DEADZONE)
|
||||
_isUsingGamepad = true;
|
||||
else if (Mouse.current != null && Mouse.current.delta.ReadValue().magnitude > PortalConstants.GAMEPAD_DEADZONE)
|
||||
_isUsingGamepad = false;
|
||||
|
||||
float sensitivity = _isUsingGamepad ? _gamepadSensitivity : _mouseSensitivity;
|
||||
float deltaMultiplier = _isUsingGamepad ? Time.deltaTime : MOUSE_DELTA_MULTIPLIER;
|
||||
|
||||
_horizontalRotation += lookInput.x * sensitivity * deltaMultiplier;
|
||||
transform.rotation = Quaternion.Euler(0, _horizontalRotation, 0f);
|
||||
|
||||
_verticalRotation -= lookInput.y * sensitivity * deltaMultiplier;
|
||||
_verticalRotation = Mathf.Clamp(_verticalRotation, _minLookAngle, _maxLookAngle);
|
||||
|
||||
_playerCamera.localRotation = Quaternion.Euler(_verticalRotation, 0f, 0f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Smoothly rotates the player camera toward a target using spherical interpolation.
|
||||
/// Assists player aiming by gently pulling the view toward dialogue choices when looking near them.
|
||||
/// Automatically unlocks if player provides manual input.
|
||||
/// </summary>
|
||||
public void ApplyAimAssist(Vector3 targetCenter)
|
||||
{
|
||||
if (IsAimLocked && HasActiveLookInput)
|
||||
IsAimLocked = false;
|
||||
|
||||
Vector3 directionToTarget = (targetCenter - _playerCamera.position).normalized;
|
||||
float angleError = Vector3.Angle(_playerCamera.forward, directionToTarget);
|
||||
|
||||
if (IsAimLocked || (angleError > 0.01f && angleError < _aimAssistMaxAngle))
|
||||
{
|
||||
Quaternion targetRotation = Quaternion.LookRotation(directionToTarget);
|
||||
|
||||
Quaternion correctedRotation = Quaternion.Slerp(
|
||||
_playerCamera.rotation,
|
||||
targetRotation,
|
||||
_aimAssistStrength * Time.deltaTime
|
||||
);
|
||||
Quaternion newBodyRotation = Quaternion.Euler(0, correctedRotation.eulerAngles.y, 0);
|
||||
|
||||
_horizontalRotation = newBodyRotation.eulerAngles.y;
|
||||
transform.rotation = newBodyRotation;
|
||||
|
||||
float newVerticalAngle = correctedRotation.eulerAngles.x;
|
||||
|
||||
if (newVerticalAngle > 180) newVerticalAngle -= 360;
|
||||
|
||||
_verticalRotation = Mathf.Clamp(newVerticalAngle, _minLookAngle, _maxLookAngle);
|
||||
_playerCamera.localRotation = Quaternion.Euler(_verticalRotation, 0f, 0f);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a random camera shake effect for feedback (e.g., impacts, events).
|
||||
/// Uses a timer-based interval to control shake frequency.
|
||||
/// Amplitude controls both the intensity and duration of the shake.
|
||||
/// </summary>
|
||||
public void Shake(float amplitude)
|
||||
{
|
||||
_shakeTimer -= Time.deltaTime;
|
||||
|
||||
if (_shakeTimer <= 0f)
|
||||
{
|
||||
_shakeTimer = _shakeInterval * amplitude;
|
||||
|
||||
float x = Random.Range(-1f, 1f) * amplitude;
|
||||
float y = Random.Range(-1f, 1f) * amplitude;
|
||||
|
||||
Vector3 originalPos = _playerCameraPivot.position;
|
||||
_playerCameraPivot.position = originalPos + new Vector3(x, y, 0f);
|
||||
}
|
||||
}
|
||||
|
||||
public void LockAim(bool locked)
|
||||
{
|
||||
IsAimLocked = locked;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1ddcefd49548ddb4b9bc694da032caee
|
||||
@@ -0,0 +1,73 @@
|
||||
using UnityEngine;
|
||||
using Unheimlich.Managers;
|
||||
|
||||
namespace Unheimlich.Gameplay.Player
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles player movement, jumping, and gravity using CharacterController.
|
||||
/// Provides movement state information for other systems like camera head bob.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(CharacterController))]
|
||||
public class PlayerMovement : MonoBehaviour
|
||||
{
|
||||
public static PlayerMovement Instance { get; private set; }
|
||||
|
||||
[Header("Settings")]
|
||||
[SerializeField] private float _moveSpeed = 5f;
|
||||
[SerializeField] private float _jumpForce = 10f;
|
||||
[SerializeField] private float _groundedGravity = -2f;
|
||||
|
||||
public CharacterController CharacterController { get; private set; }
|
||||
|
||||
public bool IsMoving { get; private set; }
|
||||
|
||||
private float _ySpeed;
|
||||
private Vector3 _moveDirection;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this)
|
||||
{
|
||||
Destroy(this);
|
||||
return;
|
||||
}
|
||||
Instance = this;
|
||||
|
||||
CharacterController = GetComponent<CharacterController>();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!PlayerInputManager.IsInputEnabled || CharacterController == null)
|
||||
return;
|
||||
|
||||
HandleMovement();
|
||||
}
|
||||
|
||||
private void HandleMovement()
|
||||
{
|
||||
Vector2 input = PlayerInputManager.Instance.MoveInput;
|
||||
|
||||
Vector3 forward = transform.forward * input.y;
|
||||
Vector3 right = transform.right * input.x;
|
||||
|
||||
_moveDirection = forward + right;
|
||||
_moveDirection = _moveDirection.normalized * _moveSpeed;
|
||||
|
||||
IsMoving = _moveDirection.magnitude > 0.1f && CharacterController.isGrounded;
|
||||
|
||||
if (CharacterController.isGrounded)
|
||||
{
|
||||
_ySpeed = _groundedGravity;
|
||||
|
||||
if (PlayerInputManager.Instance.JumpAction.action.WasPressedThisFrame())
|
||||
_ySpeed = _jumpForce;
|
||||
}
|
||||
else
|
||||
_ySpeed += Physics.gravity.y * Time.deltaTime;
|
||||
|
||||
_moveDirection.y = _ySpeed;
|
||||
CharacterController.Move(_moveDirection * Time.deltaTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a25b5737428861f4f8460e0ec5c93015
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 391e04aa0b0405147bbca7f6bde01ea6
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,734 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering.Universal;
|
||||
using Unheimlich.Managers;
|
||||
using Unheimlich.Utility;
|
||||
|
||||
namespace Unheimlich.Gameplay.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();
|
||||
|
||||
// Use DefaultHDR for better shadow and lighting compatibility in URP
|
||||
_viewTexture = new RenderTexture(Screen.width, Screen.height, 24, RenderTextureFormat.DefaultHDR);
|
||||
_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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5bee016a7303e2c458585f5cc66817e2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 200
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,45 @@
|
||||
using UnityEngine;
|
||||
using Unheimlich.Gameplay.Environment;
|
||||
|
||||
namespace Unheimlich.Gameplay.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(GameObject interactor)
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f96b3c121a38b8e4784628da571e6c5b
|
||||
@@ -0,0 +1,67 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
|
||||
namespace Unheimlich.Gameplay.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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 98eb193612a097646bb0d333bb4aae51
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 300
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,147 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.Gameplay.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e825afa0efeb69949bf3ca8b3f99d7eb
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 400
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user