chore: Project Restructuring & Theatre
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a469312c19fe2fb40baa7642b1e73dcd
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6e81c29b1e7997340a20f4c98069dc1c
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,38 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.Core.Audio
|
||||
{
|
||||
[CreateAssetMenu(fileName = "AudioEmitterData", menuName = "Unheimlich/Audio/Audio Emitter Data")]
|
||||
public class AudioEmitterData : ScriptableObject
|
||||
{
|
||||
[Header("Emitter Info")]
|
||||
public AudioEmitterType EmitterType = AudioEmitterType.Generic;
|
||||
public string DisplayName = "Generic";
|
||||
|
||||
[Header("Procedural Audio Settings")]
|
||||
public float BaseFrequency = 440f;
|
||||
public float FrequencyVariation = 50f;
|
||||
public float VolumeMin = 0.3f;
|
||||
public float VolumeMax = 0.7f;
|
||||
public float DurationMin = 0.5f;
|
||||
public float DurationMax = 2f;
|
||||
|
||||
[Header("Harmonics (for complex sounds)")]
|
||||
public float[] HarmonicMultipliers = { 1f };
|
||||
public float[] HarmonicAmplitudes = { 1f };
|
||||
|
||||
[Header("Noise Settings")]
|
||||
[Range(0f, 1f)]
|
||||
public float NoiseAmount = 0f;
|
||||
|
||||
[Header("Vibration Settings")]
|
||||
public bool EnableVibration = true;
|
||||
public float VibrationIntensityMin = 0.1f;
|
||||
public float VibrationIntensityMax = 0.5f;
|
||||
public float VibrationFrequency = 10f;
|
||||
|
||||
[Header("Distance Settings")]
|
||||
public float MaxDistance = 10f;
|
||||
public AnimationCurve DistanceAttenuation = AnimationCurve.EaseInOut(0f, 1f, 1f, 0f);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c20752cb7065e844aaaedf606c4df2db
|
||||
@@ -0,0 +1,434 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using Unheimlich.Core.Utils;
|
||||
using Unheimlich.World.CameraSystem;
|
||||
|
||||
namespace Unheimlich.Core.Audio
|
||||
{
|
||||
public enum AudioEmitterType
|
||||
{
|
||||
Generic,
|
||||
Wall,
|
||||
Wood,
|
||||
Metal,
|
||||
Glass,
|
||||
Water,
|
||||
Stone,
|
||||
Mechanical,
|
||||
Electrical,
|
||||
Organic
|
||||
}
|
||||
|
||||
public class AudioListenable : MonoBehaviour
|
||||
{
|
||||
// String constants
|
||||
private const string CONTINUOUS_AUDIO_SOURCE_NAME = "Continuous Audio Source";
|
||||
|
||||
// Volume multipliers for concentration system
|
||||
private const float BASELINE_VOLUME_MULTIPLIER = 0.1f;
|
||||
private const float FULL_VOLUME_MULTIPLIER = 1.5f;
|
||||
private const float CONCENTRATION_FADE_DURATION = 1f;
|
||||
private const float RELEASE_FADE_DURATION = 0.5f;
|
||||
|
||||
[Header("Emitter Settings")]
|
||||
[SerializeField] private AudioEmitterType _emitterType = AudioEmitterType.Generic;
|
||||
[SerializeField] private bool _useProceduralAudio = true;
|
||||
|
||||
[Header("Continuous Emission")]
|
||||
[SerializeField] private bool _emitContinuously = true;
|
||||
[SerializeField] private float _continuousBaseVolume = 0.1f;
|
||||
[SerializeField] private float _hearingRangeMin = 4f;
|
||||
[SerializeField] private float _hearingRangeMax = 8f;
|
||||
|
||||
[Header("Listening Enhancement")]
|
||||
[SerializeField] private float _reverbWetMix = 0.5f;
|
||||
|
||||
[Header("Static Audio")]
|
||||
[SerializeField] private AudioClip _staticAudioClip;
|
||||
[SerializeField] private bool _loopStaticAudio = true;
|
||||
|
||||
[Header("Procedural Settings")]
|
||||
[SerializeField] private float _baseFrequency = 440f;
|
||||
[SerializeField] private float _frequencyVariation = 50f;
|
||||
[SerializeField] private float _volumeMin = 0.3f;
|
||||
[SerializeField] private float _volumeMax = 0.7f;
|
||||
[SerializeField] private float _durationMin = 0.5f;
|
||||
[SerializeField] private float _durationMax = 2f;
|
||||
|
||||
[Header("Distance Attenuation")]
|
||||
[SerializeField] private float _maxDistance = 10f;
|
||||
[SerializeField] private AnimationCurve _distanceAttenuation = AnimationCurve.EaseInOut(0f, 1f, 1f, 0f);
|
||||
|
||||
[Header("Visual Feedback")]
|
||||
[SerializeField] private ParticleSystem _emitterParticles;
|
||||
|
||||
[Header("Camera Settings")]
|
||||
[SerializeField] private CameraPanSettings _cameraPanSettings;
|
||||
|
||||
private AudioSource _audioSource;
|
||||
private AudioSource _continuousAudioSource;
|
||||
private AudioReverbFilter _reverbFilter;
|
||||
private bool _isCurrentlyListening;
|
||||
private float _listeningTimer;
|
||||
private float _nextProceduralEventTime;
|
||||
private Vector3 _listenerPosition;
|
||||
private bool _hasBeenTapped;
|
||||
private AudioClip _continuousClip;
|
||||
private Coroutine _volumeFadeCoroutine;
|
||||
private Coroutine _startListeningCoroutine;
|
||||
private int _concentrationFadeTweenId = -1;
|
||||
private int _crossfadeTweenId = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this emitter has been tapped during the current listening session.
|
||||
/// Used to prevent multiple taps on the same emitter.
|
||||
/// </summary>
|
||||
public bool HasBeenTapped => _hasBeenTapped;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the camera pan settings for this emitter, or null if not set.
|
||||
/// </summary>
|
||||
public CameraPanSettings CameraPanSettings => _cameraPanSettings;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_audioSource = GetComponent<AudioSource>();
|
||||
if (_audioSource == null)
|
||||
_audioSource = gameObject.AddComponent<AudioSource>();
|
||||
|
||||
_audioSource.spatialBlend = 1f;
|
||||
_audioSource.maxDistance = _maxDistance;
|
||||
_audioSource.rolloffMode = AudioRolloffMode.Custom;
|
||||
_audioSource.playOnAwake = false;
|
||||
|
||||
_reverbFilter = gameObject.AddComponent<AudioReverbFilter>();
|
||||
_reverbFilter.reverbPreset = AudioReverbPreset.Off;
|
||||
_reverbFilter.dryLevel = 0f;
|
||||
_reverbFilter.room = -10000;
|
||||
|
||||
GameObject continuousSourceObject = new GameObject(CONTINUOUS_AUDIO_SOURCE_NAME);
|
||||
continuousSourceObject.transform.SetParent(transform);
|
||||
continuousSourceObject.transform.localPosition = Vector3.zero;
|
||||
_continuousAudioSource = continuousSourceObject.AddComponent<AudioSource>();
|
||||
_continuousAudioSource.spatialBlend = 1f;
|
||||
_continuousAudioSource.maxDistance = _hearingRangeMax;
|
||||
_continuousAudioSource.minDistance = _hearingRangeMin;
|
||||
_continuousAudioSource.rolloffMode = AudioRolloffMode.Linear;
|
||||
_continuousAudioSource.playOnAwake = false;
|
||||
_continuousAudioSource.loop = true;
|
||||
|
||||
if (_emitterParticles != null)
|
||||
{
|
||||
_emitterParticles.Stop();
|
||||
var emission = _emitterParticles.emission;
|
||||
emission.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (_emitContinuously && _useProceduralAudio)
|
||||
{
|
||||
StartContinuousEmission();
|
||||
}
|
||||
}
|
||||
|
||||
private void StartContinuousEmission()
|
||||
{
|
||||
float frequency = _baseFrequency + Random.Range(-_frequencyVariation * 0.5f, _frequencyVariation * 0.5f);
|
||||
_continuousClip = GenerateProceduralAudioClip(frequency, 10f);
|
||||
|
||||
_continuousAudioSource.clip = _continuousClip;
|
||||
_continuousAudioSource.volume = _continuousBaseVolume * BASELINE_VOLUME_MULTIPLIER;
|
||||
_continuousAudioSource.loop = true;
|
||||
_continuousAudioSource.Play();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the concentration state and fades the emitter volume accordingly.
|
||||
/// When concentrating: fades from 30% to 100% over 1 second.
|
||||
/// When releasing: fades from 100% to 30% over 0.5 seconds.
|
||||
/// </summary>
|
||||
public void SetConcentrating(bool concentrating)
|
||||
{
|
||||
TweenHelper.CancelTween(ref _concentrationFadeTweenId);
|
||||
|
||||
if (_continuousAudioSource == null)
|
||||
return;
|
||||
|
||||
float baseVolume = _continuousBaseVolume;
|
||||
float fromMultiplier = concentrating ? BASELINE_VOLUME_MULTIPLIER : FULL_VOLUME_MULTIPLIER;
|
||||
float toMultiplier = concentrating ? FULL_VOLUME_MULTIPLIER : BASELINE_VOLUME_MULTIPLIER;
|
||||
float duration = concentrating ? CONCENTRATION_FADE_DURATION : RELEASE_FADE_DURATION;
|
||||
|
||||
float fromVolume = baseVolume * fromMultiplier;
|
||||
float toVolume = baseVolume * toMultiplier;
|
||||
|
||||
_concentrationFadeTweenId = TweenHelper.FadeVolume(
|
||||
_continuousAudioSource,
|
||||
toVolume,
|
||||
duration);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the listening sequence for this emitter.
|
||||
/// Blocked if the emitter has already been tapped during this session.
|
||||
/// </summary>
|
||||
public void StartListening(Vector3 listenerPosition)
|
||||
{
|
||||
// Prevent multiple taps on the same emitter
|
||||
if (_hasBeenTapped)
|
||||
return;
|
||||
|
||||
if (_startListeningCoroutine != null)
|
||||
StopCoroutine(_startListeningCoroutine);
|
||||
|
||||
_startListeningCoroutine = StartCoroutine(StartListeningSequence(listenerPosition));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coroutine that handles the full listening sequence:
|
||||
/// - Crossfades from continuous to procedural audio
|
||||
/// - Activates particles
|
||||
/// - Marks emitter as tapped (prevents re-triggering)
|
||||
/// - Starts vibration
|
||||
/// </summary>
|
||||
private IEnumerator StartListeningSequence(Vector3 listenerPosition)
|
||||
{
|
||||
_isCurrentlyListening = true;
|
||||
_listenerPosition = listenerPosition;
|
||||
_listeningTimer = 0f;
|
||||
_nextProceduralEventTime = 0f;
|
||||
|
||||
// Mark as tapped to prevent re-triggering
|
||||
_hasBeenTapped = true;
|
||||
|
||||
if (_volumeFadeCoroutine != null)
|
||||
StopCoroutine(_volumeFadeCoroutine);
|
||||
|
||||
TweenHelper.CancelTween(ref _crossfadeTweenId);
|
||||
|
||||
float crossfadeDuration = 1f;
|
||||
|
||||
if (_continuousAudioSource != null)
|
||||
{
|
||||
_crossfadeTweenId = TweenHelper.FadeVolume(
|
||||
_continuousAudioSource,
|
||||
0f,
|
||||
crossfadeDuration,
|
||||
() =>
|
||||
{
|
||||
if (_continuousAudioSource != null)
|
||||
_continuousAudioSource.Stop();
|
||||
});
|
||||
}
|
||||
|
||||
if (_emitterParticles != null)
|
||||
{
|
||||
var emission = _emitterParticles.emission;
|
||||
emission.enabled = true;
|
||||
_emitterParticles.Play();
|
||||
}
|
||||
|
||||
EnableReverb(true);
|
||||
|
||||
yield return new WaitForSeconds(crossfadeDuration);
|
||||
|
||||
if (_useProceduralAudio)
|
||||
{
|
||||
PlayProceduralSound();
|
||||
}
|
||||
else if (_staticAudioClip != null)
|
||||
{
|
||||
_audioSource.clip = _staticAudioClip;
|
||||
_audioSource.loop = _loopStaticAudio;
|
||||
_audioSource.Play();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the listening mode and resets the emitter state.
|
||||
/// This is the only place where _hasBeenTapped is reset to false,
|
||||
/// allowing the emitter to be used again in a future listening session.
|
||||
/// </summary>
|
||||
public void StopListening(bool cancelConcentrationFade = true)
|
||||
{
|
||||
bool wasBeenTapped = _hasBeenTapped;
|
||||
|
||||
_isCurrentlyListening = false;
|
||||
_hasBeenTapped = false;
|
||||
|
||||
if (_volumeFadeCoroutine != null)
|
||||
StopCoroutine(_volumeFadeCoroutine);
|
||||
|
||||
if (_startListeningCoroutine != null)
|
||||
StopCoroutine(_startListeningCoroutine);
|
||||
|
||||
if (cancelConcentrationFade)
|
||||
TweenHelper.CancelTween(ref _concentrationFadeTweenId);
|
||||
|
||||
if (_audioSource != null && _audioSource.isPlaying)
|
||||
_audioSource.Stop();
|
||||
|
||||
if (_emitterParticles != null && wasBeenTapped)
|
||||
{
|
||||
_emitterParticles.Stop();
|
||||
var emission = _emitterParticles.emission;
|
||||
emission.enabled = false;
|
||||
}
|
||||
|
||||
if (_continuousAudioSource != null && !_continuousAudioSource.isPlaying && gameObject.activeInHierarchy)
|
||||
{
|
||||
StartContinuousEmission();
|
||||
}
|
||||
|
||||
EnableReverb(false);
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
|
||||
if (!_isCurrentlyListening)
|
||||
return;
|
||||
|
||||
_listeningTimer += Time.deltaTime;
|
||||
|
||||
if (_useProceduralAudio && _listeningTimer >= _nextProceduralEventTime)
|
||||
{
|
||||
PlayProceduralSound();
|
||||
_nextProceduralEventTime = _listeningTimer + Random.Range(_durationMin, _durationMax);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void EnableReverb(bool enable)
|
||||
{
|
||||
if (_reverbFilter == null)
|
||||
return;
|
||||
|
||||
if (enable)
|
||||
{
|
||||
_reverbFilter.reverbPreset = AudioReverbPreset.Room;
|
||||
_reverbFilter.dryLevel = Mathf.Lerp(0f, -1000f, 1f - _reverbWetMix);
|
||||
_reverbFilter.room = Mathf.Lerp(-1000f, -100f, _reverbWetMix);
|
||||
}
|
||||
else
|
||||
{
|
||||
_reverbFilter.reverbPreset = AudioReverbPreset.Off;
|
||||
_reverbFilter.dryLevel = 0f;
|
||||
_reverbFilter.room = -10000;
|
||||
}
|
||||
}
|
||||
|
||||
private void PlayProceduralSound()
|
||||
{
|
||||
float distance = Vector3.Distance(_listenerPosition, transform.position);
|
||||
float distanceFactor = Mathf.Clamp01(1f - (distance / _maxDistance));
|
||||
float attenuation = _distanceAttenuation.Evaluate(distanceFactor);
|
||||
|
||||
float frequency = _baseFrequency + Random.Range(-_frequencyVariation, _frequencyVariation);
|
||||
float volume = Mathf.Lerp(_volumeMin, _volumeMax, Random.value) * attenuation;
|
||||
|
||||
AudioClip proceduralClip = GenerateProceduralAudioClip(frequency, Random.Range(_durationMin, _durationMax));
|
||||
|
||||
_audioSource.clip = proceduralClip;
|
||||
_audioSource.volume = volume;
|
||||
_audioSource.loop = false;
|
||||
_audioSource.Play();
|
||||
}
|
||||
|
||||
private AudioClip GenerateProceduralAudioClip(float frequency, float duration)
|
||||
{
|
||||
int sampleRate = 44100;
|
||||
int sampleCount = Mathf.RoundToInt(sampleRate * duration);
|
||||
|
||||
AudioClip clip = AudioClip.Create("ProceduralSound", sampleCount, 1, sampleRate, false);
|
||||
float[] samples = new float[sampleCount];
|
||||
|
||||
for (int i = 0; i < sampleCount; i++)
|
||||
{
|
||||
float t = (float)i / sampleRate;
|
||||
float envelope = Mathf.Sin(Mathf.PI * t / duration);
|
||||
|
||||
samples[i] = GenerateSampleForType(frequency, t, _emitterType) * envelope;
|
||||
}
|
||||
|
||||
clip.SetData(samples, 0);
|
||||
return clip;
|
||||
}
|
||||
|
||||
private float GenerateSampleForType(float frequency, float time, AudioEmitterType type)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case AudioEmitterType.Metal:
|
||||
return Mathf.Sin(2f * Mathf.PI * frequency * time) * 0.5f +
|
||||
Mathf.Sin(2f * Mathf.PI * frequency * 2.1f * time) * 0.3f +
|
||||
Mathf.Sin(2f * Mathf.PI * frequency * 3.8f * time) * 0.2f;
|
||||
|
||||
case AudioEmitterType.Wood:
|
||||
return Mathf.Sin(2f * Mathf.PI * frequency * time) * 0.6f +
|
||||
(Random.value - 0.5f) * 0.1f;
|
||||
|
||||
case AudioEmitterType.Glass:
|
||||
return Mathf.Sin(2f * Mathf.PI * frequency * 1.5f * time) * 0.4f +
|
||||
Mathf.Sin(2f * Mathf.PI * frequency * 2.7f * time) * 0.3f +
|
||||
Mathf.Sin(2f * Mathf.PI * frequency * 4.2f * time) * 0.3f;
|
||||
|
||||
case AudioEmitterType.Stone:
|
||||
return Mathf.Sin(2f * Mathf.PI * frequency * 0.8f * time) * 0.5f +
|
||||
(Random.value - 0.5f) * 0.3f;
|
||||
|
||||
case AudioEmitterType.Water:
|
||||
return (Random.value - 0.5f) * 0.5f +
|
||||
Mathf.Sin(2f * Mathf.PI * frequency * 0.5f * time) * 0.3f;
|
||||
|
||||
case AudioEmitterType.Mechanical:
|
||||
return Mathf.Sin(2f * Mathf.PI * frequency * time) * 0.4f +
|
||||
Mathf.Sign(Mathf.Sin(2f * Mathf.PI * frequency * 0.3f * time)) * 0.3f;
|
||||
|
||||
case AudioEmitterType.Electrical:
|
||||
return (Random.value - 0.5f) * 0.6f +
|
||||
Mathf.Sin(2f * Mathf.PI * frequency * 50f * time) * 0.2f;
|
||||
|
||||
case AudioEmitterType.Organic:
|
||||
return Mathf.Sin(2f * Mathf.PI * frequency * 0.7f * time) * 0.5f +
|
||||
Mathf.Sin(2f * Mathf.PI * frequency * 1.3f * time) * 0.3f +
|
||||
(Random.value - 0.5f) * 0.2f;
|
||||
|
||||
case AudioEmitterType.Wall:
|
||||
return Mathf.Sin(2f * Mathf.PI * frequency * 0.5f * time) * 0.3f +
|
||||
(Random.value - 0.5f) * 0.1f;
|
||||
|
||||
default:
|
||||
return Mathf.Sin(2f * Mathf.PI * frequency * time);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
StopListening();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_continuousAudioSource != null && !_continuousAudioSource.isPlaying && _emitContinuously)
|
||||
{
|
||||
StartContinuousEmission();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
Gizmos.color = Color.cyan;
|
||||
Gizmos.DrawWireSphere(transform.position, _maxDistance);
|
||||
|
||||
if (_isCurrentlyListening)
|
||||
{
|
||||
Gizmos.color = Color.green;
|
||||
Gizmos.DrawLine(transform.position, _listenerPosition);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e15490f7adacb594cb114f535e71d6b6
|
||||
@@ -0,0 +1,156 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.Core.Audio
|
||||
{
|
||||
/// <summary>
|
||||
/// Base class for components that react to an AudioSource's playback state.
|
||||
/// Supports three fire conditions: OnPlay, OnFinish, and OnTimeReached.
|
||||
/// Subclasses implement Execute() to define what happens when the condition is met.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(AudioSource))]
|
||||
public abstract class AudioTriggerBase : MonoBehaviour
|
||||
{
|
||||
public enum FireCondition
|
||||
{
|
||||
/// <summary>Fire as soon as the audio starts playing.</summary>
|
||||
OnPlay,
|
||||
/// <summary>Fire when the audio clip finishes.</summary>
|
||||
OnFinish,
|
||||
/// <summary>Fire when playback reaches a specific timestamp.</summary>
|
||||
OnTimeReached
|
||||
}
|
||||
|
||||
[Header("Audio Trigger")]
|
||||
[Tooltip("When to fire the effect")]
|
||||
[SerializeField] protected FireCondition _fireCondition = FireCondition.OnPlay;
|
||||
|
||||
[Tooltip("Playback time in seconds that triggers the effect (OnTimeReached only)")]
|
||||
[SerializeField] protected float _triggerTime = 1f;
|
||||
|
||||
[Header("Behaviour")]
|
||||
[Tooltip("Prevent firing more than once per session")]
|
||||
[SerializeField] protected bool _fireOnlyOnce = true;
|
||||
|
||||
[Header("Debug")]
|
||||
[SerializeField] protected bool _debugMode;
|
||||
|
||||
protected AudioSource AudioSource { get; private set; }
|
||||
|
||||
private bool _hasFired;
|
||||
private bool _isMonitoring;
|
||||
private Coroutine _finishWatcher;
|
||||
|
||||
protected virtual void Awake()
|
||||
{
|
||||
AudioSource = GetComponent<AudioSource>();
|
||||
}
|
||||
|
||||
protected virtual void OnEnable()
|
||||
{
|
||||
_hasFired = false;
|
||||
_isMonitoring = false;
|
||||
|
||||
if (AudioSource != null && AudioSource.isPlaying)
|
||||
StartMonitoring();
|
||||
}
|
||||
|
||||
protected virtual void OnDisable()
|
||||
{
|
||||
StopMonitoring();
|
||||
}
|
||||
|
||||
// ─── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Notify this component that the AudioSource has started playing.
|
||||
/// Wire this to whatever code or event starts the AudioSource.
|
||||
/// </summary>
|
||||
public void OnAudioPlay()
|
||||
{
|
||||
StartMonitoring();
|
||||
|
||||
if (_fireCondition == FireCondition.OnPlay)
|
||||
TryExecute();
|
||||
}
|
||||
|
||||
/// <summary>Resets the fired state so this can fire again.</summary>
|
||||
public void ResetFired()
|
||||
{
|
||||
_hasFired = false;
|
||||
StopMonitoring();
|
||||
}
|
||||
|
||||
// ─── Monitoring ────────────────────────────────────────────────────────────
|
||||
|
||||
private void StartMonitoring()
|
||||
{
|
||||
_isMonitoring = true;
|
||||
|
||||
if (_fireCondition == FireCondition.OnFinish)
|
||||
{
|
||||
StopMonitoring();
|
||||
_finishWatcher = StartCoroutine(WatchForFinish());
|
||||
}
|
||||
}
|
||||
|
||||
private void StopMonitoring()
|
||||
{
|
||||
_isMonitoring = false;
|
||||
|
||||
if (_finishWatcher != null)
|
||||
{
|
||||
StopCoroutine(_finishWatcher);
|
||||
_finishWatcher = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!_isMonitoring || (_fireOnlyOnce && _hasFired))
|
||||
return;
|
||||
|
||||
if (_fireCondition != FireCondition.OnTimeReached)
|
||||
return;
|
||||
|
||||
if (AudioSource != null && AudioSource.isPlaying && AudioSource.time >= _triggerTime)
|
||||
TryExecute();
|
||||
}
|
||||
|
||||
// Uses a coroutine instead of Update polling so the finish detection is frame-accurate
|
||||
// even for very short clips that could end between two Update calls.
|
||||
private IEnumerator WatchForFinish()
|
||||
{
|
||||
if (AudioSource == null)
|
||||
yield break;
|
||||
|
||||
// Wait until the clip actually has a length to work with
|
||||
yield return null;
|
||||
|
||||
if (AudioSource.clip != null)
|
||||
yield return new WaitForSeconds(AudioSource.clip.length);
|
||||
else
|
||||
yield return new WaitUntil(() => !AudioSource.isPlaying);
|
||||
|
||||
TryExecute();
|
||||
_finishWatcher = null;
|
||||
}
|
||||
|
||||
// ─── Internal ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void TryExecute()
|
||||
{
|
||||
if (_fireOnlyOnce && _hasFired)
|
||||
return;
|
||||
|
||||
_hasFired = true;
|
||||
StopMonitoring();
|
||||
Execute();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when the fire condition is met. Implement the payload here.
|
||||
/// </summary>
|
||||
protected abstract void Execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 16d04d63a4903d54dbd0eee78c2ba121
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Collections.Generic;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
|
||||
public enum WwiseMusicState { Menu, Sane, Staircase, Mad, None};
|
||||
public class Audio_AudioManager : MonoBehaviour
|
||||
{
|
||||
public static Audio_AudioManager Instance;
|
||||
|
||||
private bool isInitialized = false;
|
||||
|
||||
|
||||
|
||||
[Header("Music State variables")]
|
||||
[SerializeField] private AK.Wwise.State MusicGlobal_Menu;
|
||||
[SerializeField] private AK.Wwise.State MusicGlobal_Sane;
|
||||
[SerializeField] private AK.Wwise.State MusicGlobal_Staircase;
|
||||
[SerializeField] private AK.Wwise.State MusicGlobal_Mad;
|
||||
[SerializeField] private AK.Wwise.State MusicGlobal_None;
|
||||
|
||||
private WwiseMusicState currentMusicState;
|
||||
|
||||
[Header("Wwise Music Events")]
|
||||
public AK.Wwise.Event PlayMusic;
|
||||
public AK.Wwise.Event StopMusic;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
Initialize();
|
||||
}
|
||||
|
||||
// Start is called once before the first execution of Update after the MonoBehaviour is created
|
||||
void Start()
|
||||
{
|
||||
SetWwiseMusicState(WwiseMusicState.Sane); //Need to switch if we have a menu music.
|
||||
|
||||
PlayMusic.Post(gameObject);
|
||||
}
|
||||
|
||||
|
||||
|
||||
void Initialize()
|
||||
{
|
||||
//Singleton Logic
|
||||
if (Instance == null)
|
||||
{
|
||||
Instance = this;
|
||||
DontDestroyOnLoad(this);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning("AudioManager already exist! Destroying new ibnstance.");
|
||||
Destroy(this);
|
||||
}
|
||||
|
||||
if (isInitialized)
|
||||
{
|
||||
isInitialized = true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
public void SetWwiseMusicState(WwiseMusicState MusicState)
|
||||
{
|
||||
if (MusicState == currentMusicState)
|
||||
{
|
||||
Debug.Log("MusicState is already" + MusicState + ".");
|
||||
return;
|
||||
}
|
||||
switch (MusicState)
|
||||
{
|
||||
default:
|
||||
case (WwiseMusicState.Menu):
|
||||
MusicGlobal_Menu.SetValue();
|
||||
break;
|
||||
case (WwiseMusicState.Sane):
|
||||
MusicGlobal_Sane.SetValue();
|
||||
break;
|
||||
case (WwiseMusicState.Staircase):
|
||||
MusicGlobal_Staircase.SetValue();
|
||||
break;
|
||||
case (WwiseMusicState.Mad):
|
||||
MusicGlobal_Mad.SetValue();
|
||||
break;
|
||||
case (WwiseMusicState.None):
|
||||
MusicGlobal_None.SetValue();
|
||||
break;
|
||||
|
||||
}
|
||||
|
||||
Debug.Log("New Wwise MusicState" + MusicState + ".");
|
||||
|
||||
currentMusicState = MusicState;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 53583b7aad5dc7044bbd6c7946fe6999
|
||||
@@ -0,0 +1,75 @@
|
||||
using UnityEngine;
|
||||
using System.Collections.Generic;
|
||||
using AK.Wwise;
|
||||
|
||||
[RequireComponent(typeof(CharacterController))]
|
||||
public class Audio_FootstepsController : MonoBehaviour
|
||||
{
|
||||
[System.Serializable]
|
||||
public struct SurfaceMapping
|
||||
{
|
||||
public string tag;
|
||||
public AK.Wwise.Switch wwiseSwitch;
|
||||
}
|
||||
|
||||
[Header("Wwise Assets")]
|
||||
public AK.Wwise.Event footstepEvent;
|
||||
|
||||
[Header("Surface Configuration")]
|
||||
|
||||
public AK.Wwise.Switch defaultSwitch;
|
||||
|
||||
[Header("Movement Settings")]
|
||||
[SerializeField] private float _stepInterval = 0.5f;
|
||||
[SerializeField] private float _minVelocity = 0.3f;
|
||||
[SerializeField] private float _rayDistance = 0.2f;
|
||||
[SerializeField] private LayerMask _groundLayer;
|
||||
|
||||
private CharacterController _controller;
|
||||
private float _timer;
|
||||
|
||||
|
||||
void Awake()
|
||||
{
|
||||
_controller = GetComponent<CharacterController>();
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (_controller.isGrounded && _controller.velocity.magnitude > _minVelocity)
|
||||
{
|
||||
_timer += Time.deltaTime;
|
||||
if (_timer >= _stepInterval)
|
||||
{
|
||||
PlayFootstep();
|
||||
_timer = 0f;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_timer = _stepInterval;
|
||||
}
|
||||
}
|
||||
|
||||
private void PlayFootstep()
|
||||
{
|
||||
RaycastHit hit;
|
||||
if (Physics.Raycast(transform.position + Vector3.up * 0.1f, Vector3.down, out hit, _rayDistance, _groundLayer))
|
||||
{
|
||||
|
||||
if (hit.collider.TryGetComponent<Audio_SoundSurface>(out var surface))
|
||||
{
|
||||
surface.wwiseSwitch.SetValue(gameObject);
|
||||
}
|
||||
else
|
||||
{
|
||||
defaultSwitch.SetValue(gameObject);
|
||||
Debug.LogWarning($"[Footsteps] No GroundSurface component on '{hit.collider.name}'. Utilisation du switch par défaut.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
footstepEvent.Post(gameObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5a11b07babb12d446a356745e7a37303
|
||||
@@ -0,0 +1,48 @@
|
||||
using UnityEngine;
|
||||
|
||||
[RequireComponent(typeof(Collider))]
|
||||
public class Audio_MusicStateTrigger : MonoBehaviour
|
||||
{
|
||||
[Header("Music State Settings")]
|
||||
[SerializeField] private WwiseMusicState targetMusicState = WwiseMusicState.Sane;
|
||||
|
||||
[Header("Trigger Settings")]
|
||||
[SerializeField] private bool triggerOnEnter = true;
|
||||
[SerializeField] private bool triggerOnExit = false;
|
||||
[SerializeField] private WwiseMusicState exitMusicState = WwiseMusicState.None;
|
||||
[SerializeField] private string playerTag = "Player";
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
// Ensure the collider is set to trigger
|
||||
GetComponent<Collider>().isTrigger = true;
|
||||
}
|
||||
|
||||
private void OnTriggerEnter(Collider other)
|
||||
{
|
||||
if (!triggerOnEnter) return;
|
||||
if (!other.CompareTag(playerTag)) return;
|
||||
|
||||
Audio_AudioManager.Instance?.SetWwiseMusicState(targetMusicState);
|
||||
}
|
||||
|
||||
private void OnTriggerExit(Collider other)
|
||||
{
|
||||
if (!triggerOnExit) return;
|
||||
if (!other.CompareTag(playerTag)) return;
|
||||
|
||||
Audio_AudioManager.Instance?.SetWwiseMusicState(exitMusicState);
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
private void OnDrawGizmos()
|
||||
{
|
||||
BoxCollider box = GetComponent<BoxCollider>();
|
||||
Gizmos.matrix = transform.localToWorldMatrix;
|
||||
Gizmos.color = new Color(0f, 1f, 0.5f, 0.25f);
|
||||
Gizmos.DrawCube(box.center, box.size);
|
||||
Gizmos.color = new Color(0f, 1f, 0.5f, 0.8f);
|
||||
Gizmos.DrawWireCube(box.center, box.size);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b91e11b451fb1494ea8c2c2af03081c7
|
||||
@@ -0,0 +1,34 @@
|
||||
using UnityEngine;
|
||||
|
||||
[RequireComponent(typeof(BoxCollider))]
|
||||
public class Audio_RTPCZone : MonoBehaviour
|
||||
{
|
||||
[HideInInspector] public Audio_RTPCZoneManager manager;
|
||||
|
||||
private bool _triggered = false;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
GetComponent<BoxCollider>().isTrigger = true;
|
||||
}
|
||||
|
||||
private void OnTriggerEnter(Collider other)
|
||||
{
|
||||
if (!other.CompareTag("Player")) return;
|
||||
if (manager.oneShot && _triggered) return;
|
||||
|
||||
_triggered = true;
|
||||
manager.OnZoneEntered();
|
||||
}
|
||||
#if UNITY_EDITOR
|
||||
private void OnDrawGizmos()
|
||||
{
|
||||
BoxCollider box = GetComponent<BoxCollider>();
|
||||
Gizmos.matrix = transform.localToWorldMatrix;
|
||||
Gizmos.color = new Color(0f, 1f, 0.5f, 0.25f);
|
||||
Gizmos.DrawCube(box.center, box.size);
|
||||
Gizmos.color = new Color(0f, 1f, 0.5f, 0.8f);
|
||||
Gizmos.DrawWireCube(box.center, box.size);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 66b5d13a3d510664faf00b6012abbe32
|
||||
@@ -0,0 +1,33 @@
|
||||
using UnityEngine;
|
||||
|
||||
public class Audio_RTPCZoneManager : MonoBehaviour
|
||||
{
|
||||
[Header("Rtpc")]
|
||||
public AK.Wwise.RTPC rtpc;
|
||||
|
||||
[Tooltip("Value added to the RTPC each time a zone is entered")]
|
||||
public float valueIncrement = 1f;
|
||||
|
||||
[Tooltip("If true, each zone only triggers once per session")]
|
||||
public bool oneShot = true;
|
||||
|
||||
[Header("Clamp")]
|
||||
public float minValue = 0f;
|
||||
public float maxValue = 10f;
|
||||
|
||||
private float _currentValue = 0f;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
foreach (var zone in GetComponentsInChildren<Audio_RTPCZone>())
|
||||
{
|
||||
zone.manager = this;
|
||||
}
|
||||
}
|
||||
|
||||
public void OnZoneEntered()
|
||||
{
|
||||
_currentValue = Mathf.Clamp(_currentValue + valueIncrement, minValue, maxValue);
|
||||
rtpc.SetGlobalValue(_currentValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 908b8bddba6d5d940b57c57ba1d2ec33
|
||||
@@ -0,0 +1,7 @@
|
||||
using UnityEngine;
|
||||
using AK.Wwise;
|
||||
|
||||
public class Audio_SoundSurface : MonoBehaviour
|
||||
{
|
||||
public AK.Wwise.Switch wwiseSwitch;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3ec78f9fa08450a4fb1c6ef69da4e22c
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8f37ef6d86cfe0a48b2cc2ceff08765c
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,96 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
using System;
|
||||
|
||||
namespace Unheimlich.Core.Events
|
||||
{
|
||||
/// <summary>
|
||||
/// Generic reusable trigger component. Handles the once-flag, optional delay,
|
||||
/// OnStart/OnEnable auto-fire, and exposes a UnityEvent + C# event for downstream systems.
|
||||
/// Compose this on any GameObject that needs to fire a game event from multiple sources.
|
||||
/// </summary>
|
||||
public class GameEventTrigger : MonoBehaviour
|
||||
{
|
||||
[Header("Auto-fire")]
|
||||
[Tooltip("Fire automatically on Start()")]
|
||||
[SerializeField] private bool _fireOnStart;
|
||||
|
||||
[Tooltip("Fire automatically when this GameObject is enabled")]
|
||||
[SerializeField] private bool _fireOnEnable;
|
||||
|
||||
[Header("Behaviour")]
|
||||
[Tooltip("Delay in seconds before the event fires")]
|
||||
[SerializeField] private float _delay;
|
||||
|
||||
[Tooltip("Prevent this trigger from firing more than once per Play Mode session")]
|
||||
[SerializeField] private bool _fireOnlyOnce = true;
|
||||
|
||||
[Header("Callbacks")]
|
||||
[Tooltip("Called when the trigger fires (after the optional delay)")]
|
||||
[SerializeField] private UnityEvent _onTriggered;
|
||||
|
||||
/// <summary>C# event raised when the trigger fires. Useful for code-only listeners.</summary>
|
||||
public event Action OnTriggered;
|
||||
|
||||
private bool _hasFired;
|
||||
|
||||
private bool _startFired;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
// Reset per-session so the trigger can fire again when entering Play Mode
|
||||
// without Domain Reload.
|
||||
_hasFired = false;
|
||||
_startFired = false;
|
||||
|
||||
// NOTE: _fireOnEnable is intentionally deferred to Start() so all Singleton
|
||||
// Awake() calls across the scene complete before listeners access managers.
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (_startFired)
|
||||
return;
|
||||
|
||||
_startFired = true;
|
||||
|
||||
// Fire once even if both flags are ticked
|
||||
if (_fireOnStart || _fireOnEnable)
|
||||
Fire();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fires the trigger. Respects the once-flag and optional delay.
|
||||
/// Call this from any external script, UnityEvent, or animation event.
|
||||
/// </summary>
|
||||
public void Fire()
|
||||
{
|
||||
if (_fireOnlyOnce && _hasFired)
|
||||
return;
|
||||
|
||||
if (_delay > 0f)
|
||||
Invoke(nameof(ExecuteTrigger), _delay);
|
||||
else
|
||||
ExecuteTrigger();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the fired state so the trigger can fire again.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
_hasFired = false;
|
||||
CancelInvoke(nameof(ExecuteTrigger));
|
||||
}
|
||||
|
||||
private void ExecuteTrigger()
|
||||
{
|
||||
if (_fireOnlyOnce && _hasFired)
|
||||
return;
|
||||
|
||||
_hasFired = true;
|
||||
_onTriggered?.Invoke();
|
||||
OnTriggered?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 358c75d041ea30a4ab85380893077e70
|
||||
@@ -0,0 +1,56 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Central event system for player actions.
|
||||
/// Decouples player components from each other.
|
||||
/// </summary>
|
||||
public static class PlayerEvents
|
||||
{
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
private static void ResetAll()
|
||||
{
|
||||
OnJump = null;
|
||||
OnTeleport = null;
|
||||
OnCameraShake = null;
|
||||
OnDiapasonEquip = null;
|
||||
OnDiapasonUnequip = null;
|
||||
OnDiapasonTap = null;
|
||||
OnEnterDialogue = null;
|
||||
OnExitDialogue = null;
|
||||
OnEnterListening = null;
|
||||
OnExitListening = null;
|
||||
}
|
||||
|
||||
// Movement events
|
||||
public static event System.Action OnJump;
|
||||
public static event System.Action<Vector3, Quaternion> OnTeleport;
|
||||
|
||||
// Camera events
|
||||
public static event System.Action<float> OnCameraShake;
|
||||
|
||||
// Diapason events
|
||||
public static event System.Action OnDiapasonEquip;
|
||||
public static event System.Action OnDiapasonUnequip;
|
||||
public static event System.Action OnDiapasonTap;
|
||||
|
||||
// Context events
|
||||
public static event System.Action OnEnterDialogue;
|
||||
public static event System.Action OnExitDialogue;
|
||||
public static event System.Action OnEnterListening;
|
||||
public static event System.Action OnExitListening;
|
||||
|
||||
// Trigger methods
|
||||
public static void TriggerJump() => OnJump?.Invoke();
|
||||
public static void TriggerTeleport(Vector3 position, Quaternion rotation) => OnTeleport?.Invoke(position, rotation);
|
||||
public static void TriggerCameraShake(float intensity) => OnCameraShake?.Invoke(intensity);
|
||||
public static void TriggerDiapasonEquip() => OnDiapasonEquip?.Invoke();
|
||||
public static void TriggerDiapasonUnequip() => OnDiapasonUnequip?.Invoke();
|
||||
public static void TriggerDiapasonTap() => OnDiapasonTap?.Invoke();
|
||||
public static void TriggerEnterDialogue() => OnEnterDialogue?.Invoke();
|
||||
public static void TriggerExitDialogue() => OnExitDialogue?.Invoke();
|
||||
public static void TriggerEnterListening() => OnEnterListening?.Invoke();
|
||||
public static void TriggerExitListening() => OnExitListening?.Invoke();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 82279ebe597d0b84eb1f7ab31139587d
|
||||
@@ -0,0 +1,87 @@
|
||||
using UnityEngine;
|
||||
using System;
|
||||
|
||||
namespace Unheimlich.Core.Events
|
||||
{
|
||||
/// <summary>
|
||||
/// Abstract base for any physics-trigger zone that fires when the player enters.
|
||||
/// Handles collider validation, player tag check, once-flag, retrigger, and Gizmos.
|
||||
/// Subclasses implement OnPlayerEntered() and optionally OnPlayerExited().
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Collider))]
|
||||
public abstract class TriggerZoneBase : MonoBehaviour
|
||||
{
|
||||
[Header("Zone Settings")]
|
||||
[SerializeField] private string _playerTag = "Player";
|
||||
|
||||
[Header("Gizmo")]
|
||||
[SerializeField] private Color _gizmoColor = new Color(0.8f, 0.5f, 0.0f, 0.35f);
|
||||
|
||||
protected bool HasFired { get; private set; }
|
||||
|
||||
private Collider _collider;
|
||||
|
||||
/// <summary>
|
||||
/// Override to control whether the zone can fire again after the first trigger.
|
||||
/// Default is false (fires once). Subclasses expose their own retrigger field and return it here.
|
||||
/// </summary>
|
||||
protected virtual bool IsRetriggerable => false;
|
||||
|
||||
protected virtual void Awake()
|
||||
{
|
||||
_collider = GetComponent<Collider>();
|
||||
|
||||
if (!_collider.isTrigger)
|
||||
{
|
||||
Debug.LogWarning($"[{GetType().Name}] Collider on '{gameObject.name}' is not a trigger. Forcing it.", this);
|
||||
_collider.isTrigger = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTriggerEnter(Collider other)
|
||||
{
|
||||
if (!other.CompareTag(_playerTag))
|
||||
return;
|
||||
|
||||
if (HasFired && !IsRetriggerable)
|
||||
return;
|
||||
|
||||
HasFired = true;
|
||||
OnPlayerEntered(other);
|
||||
}
|
||||
|
||||
private void OnTriggerExit(Collider other)
|
||||
{
|
||||
if (!other.CompareTag(_playerTag))
|
||||
return;
|
||||
|
||||
OnPlayerExited(other);
|
||||
}
|
||||
|
||||
/// <summary>Called when the player enters the trigger zone.</summary>
|
||||
protected abstract void OnPlayerEntered(Collider playerCollider);
|
||||
|
||||
/// <summary>Called when the player exits the trigger zone. Override if needed.</summary>
|
||||
protected virtual void OnPlayerExited(Collider playerCollider) { }
|
||||
|
||||
/// <summary>Resets the fired state so the zone can fire again.</summary>
|
||||
public void ResetZone()
|
||||
{
|
||||
HasFired = false;
|
||||
}
|
||||
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
Gizmos.color = _gizmoColor;
|
||||
Gizmos.matrix = transform.localToWorldMatrix;
|
||||
|
||||
Collider col = GetComponent<Collider>();
|
||||
if (col is BoxCollider box)
|
||||
Gizmos.DrawCube(box.center, box.size);
|
||||
else if (col is SphereCollider sphere)
|
||||
Gizmos.DrawSphere(sphere.center, sphere.radius);
|
||||
else if (col is CapsuleCollider capsule)
|
||||
Gizmos.DrawWireSphere(capsule.center, capsule.radius);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9fae9f4c26344344e93baeaee0281284
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 765c25ede342e8a4c8ac75e1c6507592
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,240 @@
|
||||
using UnityEngine;
|
||||
using OctoCore;
|
||||
using OctoCore.Inputs;
|
||||
using Unheimlich.Core;
|
||||
|
||||
|
||||
namespace Unheimlich.Core.Input
|
||||
{
|
||||
/// <summary>
|
||||
/// Unified input manager wrapping OctoCore inputs with Unheimlich-specific contexts.
|
||||
/// Replaces the old PlayerInputManager while keeping context system (Normal, Dialogue, Listening).
|
||||
/// </summary>
|
||||
public class InputManager : Singleton<InputManager>
|
||||
{
|
||||
public enum InputContext
|
||||
{
|
||||
Normal, // All inputs enabled
|
||||
Dialogue, // Only look and interact
|
||||
Listening, // Only look and diapason
|
||||
Inspection, // Only look and interact (for item inspection)
|
||||
DetectiveBoard // Mouse cursor free, movement disabled
|
||||
}
|
||||
|
||||
[Header("Current State")]
|
||||
[SerializeField] private InputContext _currentContext = InputContext.Normal;
|
||||
|
||||
// Public access to current context
|
||||
public static InputContext CurrentContext => Instance ? Instance._currentContext : InputContext.Normal;
|
||||
|
||||
// Input values (cached for compatibility)
|
||||
public static Vector2 MoveInput { get; private set; }
|
||||
public static Vector2 LookInput { get; private set; }
|
||||
|
||||
// Cached InputAction references — populated once during initialization
|
||||
private static UnityEngine.InputSystem.InputAction _cachedMoveAction;
|
||||
private static UnityEngine.InputSystem.InputAction _cachedLookAction;
|
||||
private static UnityEngine.InputSystem.InputAction _cachedInteractAction;
|
||||
private static UnityEngine.InputSystem.InputAction _cachedDiapasonAction;
|
||||
private static UnityEngine.InputSystem.InputAction _cachedFireAction;
|
||||
private static UnityEngine.InputSystem.InputAction _cachedExitAction;
|
||||
|
||||
// Legacy compatibility
|
||||
public static bool IsInputEnabled => CurrentContext == InputContext.Normal;
|
||||
public static bool IsLookingEnabled => CurrentContext != InputContext.Dialogue;
|
||||
|
||||
protected override void OnAwake()
|
||||
{
|
||||
// Try to subscribe, will retry in Start if needed
|
||||
SubscribeAllInputs();
|
||||
CacheAllActions();
|
||||
SetContext(InputContext.Normal);
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (OctoCore.OctoCore.Instance == null)
|
||||
{
|
||||
Debug.LogError("[InputManager] OctoCore.Instance is NULL!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (OctoCore.OctoCore.Instance.Inputs == null)
|
||||
{
|
||||
Debug.LogError("[InputManager] OctoCore.Instance.Inputs is NULL!");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Ensure we're subscribed and actions are cached
|
||||
SubscribeAllInputs();
|
||||
CacheAllActions();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
UnsubscribeAllInputs();
|
||||
}
|
||||
|
||||
// Subscribe to OctoCore inputs
|
||||
private void SubscribeAllInputs()
|
||||
{
|
||||
if (OctoCore.OctoCore.Instance == null) return;
|
||||
|
||||
OctoCore.OctoCore.Instance.Inputs.SubscribeAction(OctoActions.Player_Move, OnMove);
|
||||
OctoCore.OctoCore.Instance.Inputs.SubscribeAction(OctoActions.Player_Look, OnLook);
|
||||
OctoCore.OctoCore.Instance.Inputs.SubscribeAction(OctoActions.Player_Jump, OnJump);
|
||||
OctoCore.OctoCore.Instance.Inputs.SubscribeAction(OctoActions.Player_Interact, OnInteract);
|
||||
OctoCore.OctoCore.Instance.Inputs.SubscribeAction(OctoActions.Player_Diapason, OnDiapason);
|
||||
OctoCore.OctoCore.Instance.Inputs.SubscribeAction(OctoActions.Player_Fire, OnFire);
|
||||
OctoCore.OctoCore.Instance.Inputs.SubscribeAction(OctoActions.Player_Exit, OnExit);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Caches all InputAction references from OctoCore once at startup to avoid
|
||||
/// per-frame iteration over the Actions list.
|
||||
/// </summary>
|
||||
private void CacheAllActions()
|
||||
{
|
||||
var asset = OctoCore.OctoCore.Instance?.Inputs?.Actions;
|
||||
if (asset == null) return;
|
||||
|
||||
_cachedMoveAction = null;
|
||||
_cachedLookAction = null;
|
||||
_cachedInteractAction = null;
|
||||
_cachedDiapasonAction = null;
|
||||
_cachedFireAction = null;
|
||||
_cachedExitAction = null;
|
||||
|
||||
foreach (var actionInfo in asset)
|
||||
{
|
||||
if (actionInfo.Reference == null) continue;
|
||||
|
||||
switch (actionInfo.Name)
|
||||
{
|
||||
case "Move": _cachedMoveAction = actionInfo.Reference.action; break;
|
||||
case "Look": _cachedLookAction = actionInfo.Reference.action; break;
|
||||
case "Interact": _cachedInteractAction = actionInfo.Reference.action; break;
|
||||
case "Diapason": _cachedDiapasonAction = actionInfo.Reference.action; break;
|
||||
case "Fire": _cachedFireAction = actionInfo.Reference.action; break;
|
||||
case "Exit": _cachedExitAction = actionInfo.Reference.action; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UnsubscribeAllInputs()
|
||||
{
|
||||
if (OctoCore.OctoCore.Instance == null) return;
|
||||
|
||||
OctoCore.OctoCore.Instance.Inputs.UnsubscribeAction(OctoActions.Player_Move, OnMove);
|
||||
OctoCore.OctoCore.Instance.Inputs.UnsubscribeAction(OctoActions.Player_Look, OnLook);
|
||||
OctoCore.OctoCore.Instance.Inputs.UnsubscribeAction(OctoActions.Player_Jump, OnJump);
|
||||
OctoCore.OctoCore.Instance.Inputs.UnsubscribeAction(OctoActions.Player_Interact, OnInteract);
|
||||
OctoCore.OctoCore.Instance.Inputs.UnsubscribeAction(OctoActions.Player_Diapason, OnDiapason);
|
||||
OctoCore.OctoCore.Instance.Inputs.UnsubscribeAction(OctoActions.Player_Fire, OnFire);
|
||||
OctoCore.OctoCore.Instance.Inputs.UnsubscribeAction(OctoActions.Player_Exit, OnExit);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets input context and handles cursor/component state
|
||||
/// </summary>
|
||||
public static void SetContext(InputContext context)
|
||||
{
|
||||
if (Instance == null) return;
|
||||
|
||||
Instance._currentContext = context;
|
||||
|
||||
switch (context)
|
||||
{
|
||||
case InputContext.Normal:
|
||||
// All inputs active
|
||||
Cursor.lockState = CursorLockMode.Locked;
|
||||
Cursor.visible = false;
|
||||
break;
|
||||
|
||||
case InputContext.Dialogue:
|
||||
// Only look and interact (movement disabled)
|
||||
Cursor.lockState = CursorLockMode.None;
|
||||
Cursor.visible = true;
|
||||
break;
|
||||
|
||||
case InputContext.Listening:
|
||||
// Only look, diapason, fire, exit (movement disabled)
|
||||
Cursor.lockState = CursorLockMode.Locked;
|
||||
Cursor.visible = false;
|
||||
break;
|
||||
|
||||
case InputContext.Inspection:
|
||||
// Only look and interact (for item inspection, movement disabled)
|
||||
Cursor.lockState = CursorLockMode.Locked;
|
||||
Cursor.visible = false;
|
||||
break;
|
||||
|
||||
case InputContext.DetectiveBoard:
|
||||
// Mouse cursor free for board interaction, movement disabled
|
||||
Cursor.lockState = CursorLockMode.None;
|
||||
Cursor.visible = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy compatibility
|
||||
public static void ToggleInput(bool enable)
|
||||
{
|
||||
SetContext(enable ? InputContext.Normal : InputContext.Dialogue);
|
||||
}
|
||||
|
||||
// Input callbacks - cache values and filter by context
|
||||
private void OnMove(Vector2 value)
|
||||
{
|
||||
MoveInput = _currentContext == InputContext.Normal ? value : Vector2.zero;
|
||||
}
|
||||
|
||||
private void OnLook(Vector2 value)
|
||||
{
|
||||
LookInput = value; // Look enabled in all contexts
|
||||
}
|
||||
|
||||
private void OnJump()
|
||||
{
|
||||
if (_currentContext != InputContext.Normal) return;
|
||||
// Broadcast jump event if needed
|
||||
}
|
||||
|
||||
private void OnInteract()
|
||||
{
|
||||
if (_currentContext == InputContext.Listening) return;
|
||||
// Broadcast interact event
|
||||
}
|
||||
|
||||
private void OnDiapason()
|
||||
{
|
||||
if (_currentContext == InputContext.Dialogue) return;
|
||||
// Broadcast diapason event
|
||||
}
|
||||
|
||||
private void OnFire()
|
||||
{
|
||||
if (_currentContext != InputContext.Listening) return;
|
||||
// Broadcast fire event
|
||||
}
|
||||
|
||||
private void OnExit()
|
||||
{
|
||||
if (_currentContext != InputContext.Listening) return;
|
||||
// Broadcast exit event
|
||||
}
|
||||
|
||||
// Public accessors for backward compatibility
|
||||
public static Vector2 GetMoveInput() => MoveInput;
|
||||
public static Vector2 GetLookInput() => LookInput;
|
||||
|
||||
// Action accessors — return pre-cached references, no per-frame allocation
|
||||
public static UnityEngine.InputSystem.InputAction GetMoveAction() => _cachedMoveAction;
|
||||
public static UnityEngine.InputSystem.InputAction GetLookAction() => _cachedLookAction;
|
||||
public static UnityEngine.InputSystem.InputAction GetInteractAction() => _cachedInteractAction;
|
||||
public static UnityEngine.InputSystem.InputAction GetDiapasonAction() => _cachedDiapasonAction;
|
||||
public static UnityEngine.InputSystem.InputAction GetFireAction() => _cachedFireAction;
|
||||
public static UnityEngine.InputSystem.InputAction GetExitAction() => _cachedExitAction;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e340839b22a784542ba8afdfde612c2e
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6ad37ffd053dea24999481ef2a838247
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,67 @@
|
||||
using UnityEngine;
|
||||
using Unheimlich.Core;
|
||||
using Unheimlich.Core.Utils;
|
||||
using InputManager = Unheimlich.Core.Input.InputManager;
|
||||
|
||||
namespace Unheimlich.Core.Interaction
|
||||
{
|
||||
public class InteractionHandler : Singleton<InteractionHandler>
|
||||
{
|
||||
|
||||
[Header("Settings")]
|
||||
[SerializeField] private float _interactionRange = 1f;
|
||||
[SerializeField] private LayerMask _interactableLayers;
|
||||
[SerializeField] private float _raycastInterval = 0.1f;
|
||||
|
||||
private InteractableBase _currentInteractable;
|
||||
private Camera _playerCamera;
|
||||
private float _raycastTimer;
|
||||
|
||||
protected override void OnAwake()
|
||||
{
|
||||
_playerCamera = GetComponent<Camera>();
|
||||
}
|
||||
|
||||
private bool _didHit;
|
||||
|
||||
private void Update()
|
||||
{
|
||||
_didHit = RaycastHelper.ThrottledRaycastComponent(
|
||||
ref _raycastTimer,
|
||||
_raycastInterval,
|
||||
_playerCamera,
|
||||
_interactionRange,
|
||||
_interactableLayers,
|
||||
out InteractableBase interactable);
|
||||
|
||||
if (_didHit)
|
||||
_currentInteractable = interactable;
|
||||
else if (!_didHit && _raycastTimer <= 0f)
|
||||
_currentInteractable = null;
|
||||
|
||||
HandleInteractionInput();
|
||||
}
|
||||
|
||||
private void HandleInteractionInput()
|
||||
{
|
||||
var interactAction = InputManager.GetInteractAction();
|
||||
if (interactAction != null && interactAction.WasPressedThisFrame())
|
||||
{
|
||||
if (_currentInteractable != null)
|
||||
_currentInteractable.Interact();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
if (_playerCamera != null)
|
||||
{
|
||||
Gizmos.color = _currentInteractable != null ? Color.green : Color.red;
|
||||
|
||||
Vector3 viewportCenter = new Vector3(0.5f, 0.5f, 0f);
|
||||
Ray ray = _playerCamera.ViewportPointToRay(viewportCenter);
|
||||
Gizmos.DrawRay(ray.origin, ray.direction * _interactionRange);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d96ceb0ee513b1e4197e55e9b926269b
|
||||
@@ -0,0 +1,17 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.Core.Interaction
|
||||
{
|
||||
/// <summary>
|
||||
/// Base class for all interactable objects in the game.
|
||||
/// Derived classes must implement Interact() to define interaction behavior.
|
||||
/// </summary>
|
||||
public abstract class InteractableBase : MonoBehaviour
|
||||
{
|
||||
[Header("Interaction Settings")]
|
||||
[Tooltip("Text displayed when the player looks at the object")]
|
||||
public string InteractionText = "Interact";
|
||||
|
||||
public abstract void Interact();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c2bd6cb9667f8674294172efbdf8afd3
|
||||
@@ -0,0 +1,384 @@
|
||||
using UnityEngine;
|
||||
using System.Collections.Generic;
|
||||
using Unheimlich.Features.Dialogue.Data;
|
||||
using Unheimlich.Features.DetectiveBoard.Data;
|
||||
using Unheimlich.Features.DetectiveBoard;
|
||||
using Unheimlich.Features.Player;
|
||||
using Unheimlich.Features.Dialogue;
|
||||
using Unheimlich.Features.Dialogue.UI;
|
||||
using OctoCore.Localization;
|
||||
|
||||
namespace Unheimlich.Core.Interaction
|
||||
{
|
||||
public class ObjectDescriptionTrigger : MonoBehaviour
|
||||
{
|
||||
[Header("Description")]
|
||||
[Tooltip("Localized description text (recommended)")]
|
||||
[SerializeField] private OctoLocalizedTextData _descriptionTextLocalized;
|
||||
|
||||
[Tooltip("Fallback description text if localized version is not set")]
|
||||
[SerializeField] private string _descriptionText = "Mysterious Object";
|
||||
|
||||
[Header("Settings")]
|
||||
[SerializeField] private ObjectDescriptionSettings _settings;
|
||||
[SerializeField] private float _raycastDistance = 100f;
|
||||
|
||||
[Header("Anchor")]
|
||||
[SerializeField] private Transform _descriptionAnchor;
|
||||
|
||||
[Header("Prefab")]
|
||||
[SerializeField] private ChoiceFocusHandler _wordPrefab;
|
||||
|
||||
[Header("Detective Board Integration")]
|
||||
[Tooltip("Clue to unlock when words are fully merged (optional)")]
|
||||
[SerializeField] private ClueData _clueToUnlock;
|
||||
|
||||
private Transform _playerTransform;
|
||||
private Camera _mainCamera;
|
||||
private List<ChoiceFocusHandler> _spawnedWords = new List<ChoiceFocusHandler>();
|
||||
private GameObject _wordsContainer;
|
||||
private GameObject _wordsRotator;
|
||||
private bool _isActive = false;
|
||||
private bool _isMerged = false;
|
||||
private bool _hasBeenMergedOnce = false;
|
||||
|
||||
// Persists across OnEnable/OnDisable — only cleared by explicit ResetMerge()
|
||||
// or at the start of a new Play Mode session via RuntimeInitializeOnLoadMethod.
|
||||
private bool _hasCompletedMergeOnce = false;
|
||||
private Vector3[] _originalSpawnPositions;
|
||||
private Vector3[] _basePositions;
|
||||
private float _currentConvergenceRadius;
|
||||
private float _sortTimer = 0f;
|
||||
|
||||
private const float SORT_INTERVAL = 0.05f;
|
||||
|
||||
public bool IsAnimationActive => _isActive && (!_settings.MergeOnce || !_hasCompletedMergeOnce);
|
||||
public bool IsInMergeOnceMode => _settings != null && _settings.MergeOnce;
|
||||
|
||||
void Start()
|
||||
{
|
||||
InitializeReferences();
|
||||
}
|
||||
|
||||
void OnEnable()
|
||||
{
|
||||
// With Scene Reload disabled, Start() doesn't run again between Play Mode sessions.
|
||||
// Re-fetch references and reset transient state so animations restart cleanly.
|
||||
InitializeReferences();
|
||||
ResetSessionState();
|
||||
}
|
||||
|
||||
private void InitializeReferences()
|
||||
{
|
||||
_playerTransform = PlayerController.Instance != null ? PlayerController.Instance.transform : null;
|
||||
_mainCamera = Camera.main;
|
||||
|
||||
if (_playerTransform == null)
|
||||
Debug.LogWarning($"[ObjectDescription] No player found for {gameObject.name}");
|
||||
|
||||
if (_descriptionAnchor == null)
|
||||
_descriptionAnchor = transform;
|
||||
|
||||
if (_settings == null)
|
||||
{
|
||||
Debug.LogError($"[ObjectDescription] No settings assigned for {gameObject.name}");
|
||||
enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ResetSessionState()
|
||||
{
|
||||
// With Domain Reload disabled, static fields and per-instance flags persist between
|
||||
// Play Mode sessions. This method is called from OnEnable() to reset all transient
|
||||
// animation state so merged objects start fresh each session without needing a full reload.
|
||||
if (_isActive)
|
||||
{
|
||||
foreach (var word in _spawnedWords)
|
||||
{
|
||||
if (word != null) Destroy(word.gameObject);
|
||||
}
|
||||
_spawnedWords.Clear();
|
||||
|
||||
if (_wordsContainer != null) Destroy(_wordsContainer);
|
||||
_wordsContainer = null;
|
||||
_wordsRotator = null;
|
||||
}
|
||||
|
||||
_isActive = false;
|
||||
_isMerged = false;
|
||||
_hasBeenMergedOnce = false;
|
||||
_hasCompletedMergeOnce = false; // reset so MergeOnce objects start fresh each session
|
||||
_currentConvergenceRadius = 0f;
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (_playerTransform == null || _settings == null) return;
|
||||
|
||||
float distanceToPlayer = Vector3.Distance(_playerTransform.position, transform.position);
|
||||
|
||||
if (!_isActive && distanceToPlayer <= _settings.ActivationDistance)
|
||||
{
|
||||
if (!_settings.MergeOnce || !_hasCompletedMergeOnce)
|
||||
SpawnWords();
|
||||
}
|
||||
else if (_isActive && distanceToPlayer >= _settings.DeactivationDistance)
|
||||
DespawnWords();
|
||||
|
||||
if (_isActive)
|
||||
{
|
||||
if (!_settings.MergeOnce || !_hasBeenMergedOnce)
|
||||
{
|
||||
UpdateWordConvergence();
|
||||
UpdateContainerRotation();
|
||||
|
||||
_sortTimer += Time.deltaTime;
|
||||
if (_sortTimer >= SORT_INTERVAL)
|
||||
{
|
||||
_sortTimer = 0f;
|
||||
FloatingWordsSpawner.SortByDistance(_spawnedWords, _mainCamera);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SpawnWords()
|
||||
{
|
||||
if (_wordPrefab == null)
|
||||
{
|
||||
Debug.LogError($"[ObjectDescription] No word prefab assigned for {gameObject.name}");
|
||||
return;
|
||||
}
|
||||
|
||||
_isActive = true;
|
||||
_isMerged = false;
|
||||
_currentConvergenceRadius = _settings.SpawnRadius;
|
||||
|
||||
_wordsContainer = new GameObject($"{_descriptionText}_Container");
|
||||
_wordsContainer.transform.position = _descriptionAnchor.position;
|
||||
_wordsContainer.transform.rotation = _descriptionAnchor.rotation;
|
||||
|
||||
_wordsRotator = FloatingWordsSpawner.CreateWordsContainer(_descriptionText, _descriptionAnchor, _settings);
|
||||
_wordsRotator.transform.SetParent(_wordsContainer.transform, false);
|
||||
|
||||
ObjectDescriptionTriggerReference triggerRef = _wordsRotator.AddComponent<ObjectDescriptionTriggerReference>();
|
||||
triggerRef.Trigger = this;
|
||||
|
||||
string descText = _descriptionTextLocalized != null
|
||||
? OctoLocalization.GetLocalizedText(_descriptionTextLocalized)
|
||||
: _descriptionText;
|
||||
|
||||
List<DialogueChoiceData> dummyChoices = new List<DialogueChoiceData>();
|
||||
for (int i = 0; i < _settings.WordCount; i++)
|
||||
{
|
||||
dummyChoices.Add(new DialogueChoiceData
|
||||
{
|
||||
ChoiceText = null,
|
||||
ChoiceTextOverride = descText,
|
||||
NextNode = null
|
||||
});
|
||||
}
|
||||
|
||||
_spawnedWords = FloatingWordsSpawner.SpawnByMode(
|
||||
dummyChoices,
|
||||
_wordPrefab,
|
||||
_wordsRotator.transform,
|
||||
_settings,
|
||||
out _originalSpawnPositions
|
||||
);
|
||||
|
||||
_basePositions = new Vector3[_spawnedWords.Count];
|
||||
for (int i = 0; i < _spawnedWords.Count; i++)
|
||||
_basePositions[i] = _spawnedWords[i].transform.localPosition;
|
||||
}
|
||||
|
||||
private void DespawnWords()
|
||||
{
|
||||
_isActive = false;
|
||||
_isMerged = false;
|
||||
|
||||
foreach (var word in _spawnedWords)
|
||||
{
|
||||
if (word != null)
|
||||
Destroy(word.gameObject);
|
||||
}
|
||||
|
||||
_spawnedWords.Clear();
|
||||
|
||||
if (_wordsContainer != null)
|
||||
Destroy(_wordsContainer);
|
||||
|
||||
// Debug.Log($"[ObjectDescription] Despawned words for '{descriptionText}'");
|
||||
}
|
||||
|
||||
private void UpdateWordConvergence()
|
||||
{
|
||||
if (_mainCamera == null || _spawnedWords.Count == 0) return;
|
||||
|
||||
Vector2 screenCenter = new Vector2(Screen.width / 2f, Screen.height / 2f);
|
||||
Ray ray = _mainCamera.ScreenPointToRay(screenCenter);
|
||||
RaycastHit hit;
|
||||
|
||||
int layerMask = ~LayerMask.GetMask("Words", "UI");
|
||||
|
||||
bool isHoveringObject = false;
|
||||
|
||||
if (Physics.Raycast(ray, out hit, _raycastDistance, layerMask))
|
||||
{
|
||||
if (hit.collider.gameObject == gameObject || hit.collider.transform.IsChildOf(transform))
|
||||
isHoveringObject = true;
|
||||
}
|
||||
|
||||
float targetRadius = isHoveringObject ? _settings.MinConvergenceRadius : _settings.SpawnRadius;
|
||||
_currentConvergenceRadius = Mathf.Lerp(_currentConvergenceRadius, targetRadius, Time.deltaTime * _settings.ConvergenceSpeed);
|
||||
|
||||
if (isHoveringObject && !_isMerged && _currentConvergenceRadius <= _settings.MinConvergenceRadius * 5f)
|
||||
MergeWords();
|
||||
else if (!isHoveringObject && _isMerged)
|
||||
{
|
||||
if (!_settings.MergeOnce || !_hasBeenMergedOnce)
|
||||
UnmergeWords();
|
||||
}
|
||||
|
||||
if (_isMerged) return;
|
||||
|
||||
float convergenceFactor = Mathf.InverseLerp(0f, _settings.SpawnRadius, _currentConvergenceRadius);
|
||||
|
||||
for (int i = 0; i < _spawnedWords.Count; i++)
|
||||
{
|
||||
if (_spawnedWords[i] == null) continue;
|
||||
|
||||
Vector3 targetLocalPos = GetConvergenceTargetPosition(_originalSpawnPositions[i], _currentConvergenceRadius);
|
||||
|
||||
_basePositions[i] = Vector3.Lerp(
|
||||
_basePositions[i],
|
||||
targetLocalPos,
|
||||
Time.deltaTime * _settings.ConvergenceSpeed
|
||||
);
|
||||
|
||||
_spawnedWords[i].transform.localPosition = _basePositions[i] + _spawnedWords[i].GetShakeOffset(convergenceFactor);
|
||||
}
|
||||
}
|
||||
|
||||
private Vector3 GetConvergenceTargetPosition(Vector3 originalPosition, float radius)
|
||||
{
|
||||
if (radius < 0.01f) return Vector3.zero;
|
||||
|
||||
switch (_settings.RotationAxis)
|
||||
{
|
||||
case RotationAxis.Forward:
|
||||
Vector2 xyNormalized = new Vector2(originalPosition.x, originalPosition.y - _settings.SpawnHeightOffset).normalized;
|
||||
return new Vector3(
|
||||
xyNormalized.x * radius,
|
||||
xyNormalized.y * radius + _settings.SpawnHeightOffset,
|
||||
originalPosition.z
|
||||
);
|
||||
|
||||
case RotationAxis.Up:
|
||||
Vector2 xzNormalized = new Vector2(originalPosition.x, originalPosition.z).normalized;
|
||||
return new Vector3(
|
||||
xzNormalized.x * radius,
|
||||
originalPosition.y,
|
||||
xzNormalized.y * radius
|
||||
);
|
||||
|
||||
case RotationAxis.Right:
|
||||
Vector2 yzNormalized = new Vector2(originalPosition.y - _settings.SpawnHeightOffset, originalPosition.z).normalized;
|
||||
return new Vector3(
|
||||
originalPosition.x,
|
||||
yzNormalized.x * radius + _settings.SpawnHeightOffset,
|
||||
yzNormalized.y * radius
|
||||
);
|
||||
|
||||
default:
|
||||
return originalPosition.normalized * radius;
|
||||
}
|
||||
}
|
||||
|
||||
private void MergeWords()
|
||||
{
|
||||
if (_isMerged) return;
|
||||
|
||||
_isMerged = true;
|
||||
_hasBeenMergedOnce = true;
|
||||
|
||||
FloatingWordsSpawner.MergeWords(_spawnedWords, _settings, () =>
|
||||
{
|
||||
if (_settings.MergeOnce)
|
||||
{
|
||||
LeanTween.delayedCall(_settings.MergeOnceDisplayDuration, () =>
|
||||
{
|
||||
FadeOutAndComplete();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void FadeOutAndComplete()
|
||||
{
|
||||
if (_spawnedWords.Count > 0 && _spawnedWords[0] != null)
|
||||
{
|
||||
CanvasGroup canvasGroup = _spawnedWords[0].GetComponent<CanvasGroup>();
|
||||
if (canvasGroup != null)
|
||||
{
|
||||
LeanTween.value(_spawnedWords[0].gameObject, 1f, 0f, _settings.MergeDuration)
|
||||
.setOnUpdate((float val) => { canvasGroup.alpha = val; })
|
||||
.setEaseInQuad()
|
||||
.setOnComplete(() =>
|
||||
{
|
||||
_hasCompletedMergeOnce = true;
|
||||
UnlockClue();
|
||||
DespawnWords();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UnlockClue()
|
||||
{
|
||||
if (_clueToUnlock != null && DetectiveBoardManager.Instance != null)
|
||||
{
|
||||
DetectiveBoardManager.Instance.UnlockClue(_clueToUnlock);
|
||||
Debug.Log($"[ObjectDescription] Unlocked clue: {_clueToUnlock.name} from description merge");
|
||||
}
|
||||
}
|
||||
|
||||
private void UnmergeWords()
|
||||
{
|
||||
if (!_isMerged) return;
|
||||
|
||||
_isMerged = false;
|
||||
|
||||
FloatingWordsSpawner.UnmergeWords(_spawnedWords, _settings);
|
||||
}
|
||||
|
||||
private void UpdateContainerRotation()
|
||||
{
|
||||
FloatingWordsSpawner.UpdateContainerRotation(_wordsContainer, _wordsRotator, _playerTransform, _settings);
|
||||
}
|
||||
|
||||
public ObjectDescriptionSettings GetSettings()
|
||||
{
|
||||
return _settings;
|
||||
}
|
||||
|
||||
void OnDrawGizmosSelected()
|
||||
{
|
||||
if (_settings == null) return;
|
||||
|
||||
Transform anchor = _descriptionAnchor != null ? _descriptionAnchor : transform;
|
||||
|
||||
Gizmos.color = Color.yellow;
|
||||
Gizmos.DrawWireSphere(transform.position, _settings.ActivationDistance);
|
||||
|
||||
Gizmos.color = Color.red;
|
||||
Gizmos.DrawWireSphere(transform.position, _settings.DeactivationDistance);
|
||||
|
||||
Gizmos.color = Color.green;
|
||||
Gizmos.DrawWireSphere(anchor.position, _settings.SpawnRadius);
|
||||
|
||||
Gizmos.color = Color.cyan;
|
||||
Gizmos.DrawWireSphere(anchor.position, _settings.MinConvergenceRadius);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a1fe462730ebdae488f89f5868a4c74c
|
||||
@@ -0,0 +1,9 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.Core.Interaction
|
||||
{
|
||||
public class ObjectDescriptionTriggerReference : MonoBehaviour
|
||||
{
|
||||
public ObjectDescriptionTrigger Trigger;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0ddb0acc42d86604c9dbf91872a216d9
|
||||
@@ -0,0 +1,173 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using Unheimlich.Core;
|
||||
using Unheimlich.Features.Items;
|
||||
using Unheimlich.Features.Player;
|
||||
using InputManager = Unheimlich.Core.Input.InputManager;
|
||||
|
||||
namespace Unheimlich.Core.Interaction
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles player item inspection mode.
|
||||
/// </summary>
|
||||
public class PlayerInspectionHandler : Singleton<PlayerInspectionHandler>
|
||||
{
|
||||
|
||||
[Header("Components")]
|
||||
[SerializeField] private Transform _inspectionHoldPoint;
|
||||
|
||||
[Header("State")]
|
||||
public bool IsInspecting { get; private set; } = false;
|
||||
private InspectableItem _currentInspectedItem;
|
||||
|
||||
private Coroutine _interactionListener;
|
||||
private bool _isUsingGamepad = false;
|
||||
|
||||
public void ToggleInspection(InspectableItem item)
|
||||
{
|
||||
if (!IsInspecting)
|
||||
{
|
||||
StartInspection(item);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_interactionListener != null)
|
||||
StopInspection();
|
||||
}
|
||||
}
|
||||
|
||||
private void StartInspection(InspectableItem item)
|
||||
{
|
||||
IsInspecting = true;
|
||||
_currentInspectedItem = item;
|
||||
|
||||
// Set Inspection context (handles cursor automatically)
|
||||
InputManager.SetContext(InputManager.InputContext.Inspection);
|
||||
|
||||
// Disable OctoPlayer to prevent movement and look
|
||||
if (PlayerController.Instance?.Movement != null)
|
||||
{
|
||||
PlayerController.Instance.Movement.enabled = false;
|
||||
}
|
||||
|
||||
item.PrepareForInspection(_inspectionHoldPoint);
|
||||
|
||||
if (_interactionListener == null)
|
||||
_interactionListener = StartCoroutine(ListenForInteraction());
|
||||
|
||||
}
|
||||
|
||||
private void StopInspection()
|
||||
{
|
||||
if (_interactionListener != null)
|
||||
{
|
||||
StopCoroutine(_interactionListener);
|
||||
_interactionListener = null;
|
||||
}
|
||||
|
||||
StopInspectionInternal();
|
||||
}
|
||||
|
||||
private void StopInspectionInternal()
|
||||
{
|
||||
if (_currentInspectedItem != null)
|
||||
{
|
||||
_currentInspectedItem.Release();
|
||||
_currentInspectedItem = null;
|
||||
|
||||
StartCoroutine(DelayedToggleInput(true));
|
||||
}
|
||||
IsInspecting = false;
|
||||
}
|
||||
|
||||
private IEnumerator DelayedToggleInput(bool enable)
|
||||
{
|
||||
yield return new WaitForEndOfFrame();
|
||||
|
||||
// Re-enable OctoPlayer
|
||||
if (PlayerController.Instance?.Movement != null)
|
||||
{
|
||||
PlayerController.Instance.Movement.enabled = true;
|
||||
}
|
||||
|
||||
// Restore Normal context (handles cursor automatically)
|
||||
InputManager.SetContext(InputManager.InputContext.Normal);
|
||||
|
||||
yield break;
|
||||
}
|
||||
|
||||
|
||||
private IEnumerator ListenForInteraction()
|
||||
{
|
||||
yield return null;
|
||||
|
||||
while (IsInspecting)
|
||||
{
|
||||
var interactAction = InputManager.GetInteractAction();
|
||||
if (interactAction != null && interactAction.WasPressedThisFrame())
|
||||
{
|
||||
StopInspectionInternal();
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return null;
|
||||
}
|
||||
_interactionListener = null;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (IsInspecting && _currentInspectedItem != null)
|
||||
{
|
||||
HandleObjectRotation();
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleObjectRotation()
|
||||
{
|
||||
if (_currentInspectedItem == null) return;
|
||||
|
||||
Vector2 lookInput = Vector2.zero;
|
||||
bool hasInput = false;
|
||||
|
||||
// Read input directly (not cached)
|
||||
if (Gamepad.current != null)
|
||||
{
|
||||
Vector2 stickInput = Gamepad.current.rightStick.ReadValue();
|
||||
if (stickInput.sqrMagnitude > 0.01f)
|
||||
{
|
||||
lookInput = stickInput;
|
||||
hasInput = true;
|
||||
_isUsingGamepad = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasInput && Mouse.current != null)
|
||||
{
|
||||
Vector2 mouseDelta = Mouse.current.delta.ReadValue();
|
||||
if (mouseDelta.sqrMagnitude > 0.01f)
|
||||
{
|
||||
lookInput = mouseDelta;
|
||||
hasInput = true;
|
||||
_isUsingGamepad = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Only rotate if there's actual input
|
||||
if (hasInput)
|
||||
{
|
||||
// Normalize to get constant rotation speed regardless of input magnitude
|
||||
Vector2 direction = lookInput.normalized;
|
||||
|
||||
float rotationMultiplier = _isUsingGamepad ? Time.deltaTime : 0.02f;
|
||||
|
||||
float rotX = direction.y * _currentInspectedItem.RotationSpeed * rotationMultiplier;
|
||||
float rotY = direction.x * _currentInspectedItem.RotationSpeed * rotationMultiplier;
|
||||
|
||||
_currentInspectedItem.transform.Rotate(transform.up, -rotY, Space.World);
|
||||
_currentInspectedItem.transform.Rotate(transform.right, rotX, Space.World);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 464960797e705e84a9eb87ecce4e9311
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 49fb0f79c3b9b1142a8e1a2f7c71b843
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,73 @@
|
||||
using UnityEngine;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Unheimlich.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Clears all Singleton static instances at the start of every Play Mode session.
|
||||
/// Necessary when Domain Reload is disabled in Enter Play Mode settings.
|
||||
/// </summary>
|
||||
internal static class SingletonRegistry
|
||||
{
|
||||
private static readonly List<Action> _resetCallbacks = new List<Action>();
|
||||
|
||||
public static void Register(Action resetCallback)
|
||||
{
|
||||
if (!_resetCallbacks.Contains(resetCallback))
|
||||
_resetCallbacks.Add(resetCallback);
|
||||
}
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
private static void ResetAll()
|
||||
{
|
||||
foreach (Action reset in _resetCallbacks)
|
||||
reset?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
public abstract class Singleton<T> : MonoBehaviour where T : MonoBehaviour
|
||||
{
|
||||
public static T Instance { get; private set; }
|
||||
|
||||
static Singleton()
|
||||
{
|
||||
SingletonRegistry.Register(() => Instance = null);
|
||||
}
|
||||
|
||||
protected virtual void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this)
|
||||
{
|
||||
Debug.LogWarning($"[{typeof(T).Name}] Multiple instances detected! Destroying duplicate on '{gameObject.name}'.");
|
||||
Destroy(this);
|
||||
return;
|
||||
}
|
||||
Instance = this as T;
|
||||
OnAwake();
|
||||
}
|
||||
|
||||
// Re-registers the instance when entering Play Mode with Scene Reload disabled.
|
||||
// SubsystemRegistration clears Instance before OnEnable runs, so we restore it here.
|
||||
protected virtual void OnEnable()
|
||||
{
|
||||
if (Instance == null)
|
||||
Instance = this as T;
|
||||
|
||||
OnEnabled();
|
||||
}
|
||||
|
||||
protected virtual void OnDestroy()
|
||||
{
|
||||
if (Instance == this)
|
||||
{
|
||||
Instance = null;
|
||||
OnDestroyInstance();
|
||||
}
|
||||
}
|
||||
|
||||
protected virtual void OnAwake() { }
|
||||
protected virtual void OnEnabled() { }
|
||||
protected virtual void OnDestroyInstance() { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 654e642c73c2b954fa86e0caac3f08c1
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2640c02bccbc7f545bba42f68c183b79
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,25 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.Core.Utils
|
||||
{
|
||||
/// <summary>
|
||||
/// Resets third-party static state that doesn't handle Domain Reload being disabled.
|
||||
/// Runs automatically at the start of every Play Mode session before the scene loads.
|
||||
/// </summary>
|
||||
internal static class GameSessionResetter
|
||||
{
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
private static void OnSessionStart()
|
||||
{
|
||||
ResetLeanTween();
|
||||
}
|
||||
|
||||
private static void ResetLeanTween()
|
||||
{
|
||||
// LeanTween holds a static tween array that persists when Domain Reload is disabled.
|
||||
// Stale tween IDs from the previous session cause callbacks to fire on wrong objects,
|
||||
// which breaks animations like organic shake on floating words.
|
||||
LeanTween.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 34edfe05577b2a24892a90d9901cef39
|
||||
@@ -0,0 +1,128 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.Core.Utils
|
||||
{
|
||||
/// <summary>
|
||||
/// Utility functions for portal camera rendering and visibility calculations.
|
||||
/// Used to optimize portal rendering by detecting visibility and overlaps.
|
||||
/// </summary>
|
||||
public static class PortalCameraUtility
|
||||
{
|
||||
private static readonly Vector3[] _cubeCornerOffsets = {
|
||||
new Vector3(1, 1, 1),
|
||||
new Vector3(-1, 1, 1),
|
||||
new Vector3(-1, -1, 1),
|
||||
new Vector3(-1, -1, -1),
|
||||
new Vector3(-1, 1, -1),
|
||||
new Vector3(1, -1, -1),
|
||||
new Vector3(1, 1, -1),
|
||||
new Vector3(1, -1, 1),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Tests if a renderer is visible from the camera's perspective.
|
||||
/// Uses frustum culling to determine visibility.
|
||||
/// </summary>
|
||||
public static bool VisibleFromCamera(Renderer renderer, Camera camera)
|
||||
{
|
||||
Plane[] frustumPlanes = GeometryUtility.CalculateFrustumPlanes(camera);
|
||||
return GeometryUtility.TestPlanesAABB(frustumPlanes, renderer.bounds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if two objects' screen-space bounding boxes overlap when viewed from a camera.
|
||||
/// Used to determine if one portal is being viewed through another portal (recursion).
|
||||
/// Returns true if the far object is visible through/behind the near object.
|
||||
/// </summary>
|
||||
public static bool BoundsOverlap(MeshFilter nearObject, MeshFilter farObject, Camera camera)
|
||||
{
|
||||
var near = GetScreenRectFromBounds(nearObject, camera);
|
||||
var far = GetScreenRectFromBounds(farObject, camera);
|
||||
|
||||
if (far.zMax > near.zMin)
|
||||
{
|
||||
if (far.xMax < near.xMin || far.xMin > near.xMax)
|
||||
return false;
|
||||
|
||||
if (far.yMax < near.yMin || far.yMin > near.yMax)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a mesh's 3D bounding box to screen-space coordinates (viewport space).
|
||||
/// Handles corner cases where parts of the mesh are behind the camera.
|
||||
/// Returns a MinMax3D struct containing the min/max viewport coordinates (x, y) and depth (z).
|
||||
/// </summary>
|
||||
public static MinMax3D GetScreenRectFromBounds(MeshFilter renderer, Camera mainCamera)
|
||||
{
|
||||
MinMax3D minMax = new MinMax3D(float.MaxValue, float.MinValue);
|
||||
var localBounds = renderer.sharedMesh.bounds;
|
||||
bool anyPointIsInFrontOfCamera = false;
|
||||
|
||||
for (int i = 0; i < 8; i++)
|
||||
{
|
||||
Vector3 localSpaceCorner = localBounds.center + Vector3.Scale(localBounds.extents, _cubeCornerOffsets[i]);
|
||||
Vector3 worldSpaceCorner = renderer.transform.TransformPoint(localSpaceCorner);
|
||||
Vector3 viewportSpaceCorner = mainCamera.WorldToViewportPoint(worldSpaceCorner);
|
||||
|
||||
if (viewportSpaceCorner.z > 0)
|
||||
{
|
||||
anyPointIsInFrontOfCamera = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
viewportSpaceCorner.x = (viewportSpaceCorner.x <= 0.5f) ? 1 : 0;
|
||||
viewportSpaceCorner.y = (viewportSpaceCorner.y <= 0.5f) ? 1 : 0;
|
||||
}
|
||||
|
||||
minMax.AddPoint(viewportSpaceCorner);
|
||||
}
|
||||
|
||||
if (!anyPointIsInFrontOfCamera)
|
||||
{
|
||||
return new MinMax3D();
|
||||
}
|
||||
|
||||
return minMax;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a 3D axis-aligned bounding box in viewport/screen space.
|
||||
/// X and Y are in viewport coordinates [0,1], Z is depth from camera.
|
||||
/// </summary>
|
||||
public struct MinMax3D
|
||||
{
|
||||
public float xMin;
|
||||
public float xMax;
|
||||
public float yMin;
|
||||
public float yMax;
|
||||
public float zMin;
|
||||
public float zMax;
|
||||
|
||||
public MinMax3D(float min, float max)
|
||||
{
|
||||
this.xMin = min;
|
||||
this.xMax = max;
|
||||
this.yMin = min;
|
||||
this.yMax = max;
|
||||
this.zMin = min;
|
||||
this.zMax = max;
|
||||
}
|
||||
|
||||
public void AddPoint(Vector3 point)
|
||||
{
|
||||
xMin = Mathf.Min(xMin, point.x);
|
||||
xMax = Mathf.Max(xMax, point.x);
|
||||
yMin = Mathf.Min(yMin, point.y);
|
||||
yMax = Mathf.Max(yMax, point.y);
|
||||
zMin = Mathf.Min(zMin, point.z);
|
||||
zMax = Mathf.Max(zMax, point.z);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4d0beb143dec2a948b0c8348fc80e02b
|
||||
@@ -0,0 +1,26 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.Core.Utils
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared constants for the portal system.
|
||||
/// Contains only hardcoded values used in multiple places (shader properties, object names, thresholds).
|
||||
/// Does NOT include default values for serialized fields (those are configured per-instance in Inspector).
|
||||
/// </summary>
|
||||
public static class PortalConstants
|
||||
{
|
||||
public const string PORTAL_CAMERA_NAME = "PortalCamera";
|
||||
|
||||
public const string SHADER_PROPERTY_MAIN_TEXTURE = "_MainTex";
|
||||
public const string SHADER_PROPERTY_DISPLAY_MASK = "_DisplayMask";
|
||||
public const string SHADER_PROPERTY_SLICE_NORMAL = "_sliceNormal";
|
||||
public const string SHADER_PROPERTY_SLICE_OFFSET = "_sliceOffsetDst";
|
||||
public const string SHADER_PROPERTY_SLICE_CENTER = "_SliceCenter";
|
||||
|
||||
public const float MIN_VOLUME_THRESHOLD = 0.001f;
|
||||
public const float GAMEPAD_DEADZONE = 0.01f;
|
||||
|
||||
public const int RENDER_TEXTURE_DEPTH = 24;
|
||||
public const RenderTextureFormat RENDER_TEXTURE_FORMAT = RenderTextureFormat.DefaultHDR;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 37282c5300e816f498b180139042682b
|
||||
@@ -0,0 +1,66 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.Core.Utils
|
||||
{
|
||||
public static class RaycastHelper
|
||||
{
|
||||
private static readonly Vector3 ViewportCenter = new Vector3(0.5f, 0.5f, 0f);
|
||||
|
||||
public static bool RaycastFromCameraCenter(Camera camera, float maxDistance,
|
||||
LayerMask layerMask, out RaycastHit hit)
|
||||
{
|
||||
if (camera == null)
|
||||
{
|
||||
hit = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
Ray ray = camera.ViewportPointToRay(ViewportCenter);
|
||||
return Physics.Raycast(ray, out hit, maxDistance, layerMask);
|
||||
}
|
||||
|
||||
public static bool RaycastFromCameraCenter<T>(Camera camera, float maxDistance,
|
||||
LayerMask layerMask, out T component) where T : Component
|
||||
{
|
||||
component = null;
|
||||
|
||||
if (RaycastFromCameraCenter(camera, maxDistance, layerMask, out RaycastHit hit))
|
||||
{
|
||||
component = hit.collider.GetComponent<T>();
|
||||
return component != null;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool ThrottledRaycast(ref float timer, float interval,
|
||||
Camera camera, float maxDistance, LayerMask layerMask, out RaycastHit hit)
|
||||
{
|
||||
timer += Time.deltaTime;
|
||||
|
||||
if (timer >= interval)
|
||||
{
|
||||
timer = 0f;
|
||||
return RaycastFromCameraCenter(camera, maxDistance, layerMask, out hit);
|
||||
}
|
||||
|
||||
hit = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool ThrottledRaycastComponent<T>(ref float timer, float interval,
|
||||
Camera camera, float maxDistance, LayerMask layerMask, out T component) where T : Component
|
||||
{
|
||||
timer += Time.deltaTime;
|
||||
|
||||
if (timer >= interval)
|
||||
{
|
||||
timer = 0f;
|
||||
return RaycastFromCameraCenter(camera, maxDistance, layerMask, out component);
|
||||
}
|
||||
|
||||
component = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f37b9866154109148b3f7d2d3c121c36
|
||||
@@ -0,0 +1,224 @@
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
|
||||
namespace Unheimlich.Core.Utils
|
||||
{
|
||||
public static class TweenHelper
|
||||
{
|
||||
public static int FadeCanvasGroup(CanvasGroup canvasGroup, float targetAlpha,
|
||||
float duration, System.Action onComplete = null,
|
||||
LeanTweenType easeType = LeanTweenType.easeInQuad)
|
||||
{
|
||||
if (canvasGroup == null) return -1;
|
||||
|
||||
LeanTween.cancel(canvasGroup.gameObject);
|
||||
return LeanTween.alphaCanvas(canvasGroup, targetAlpha, duration)
|
||||
.setEase(easeType)
|
||||
.setOnComplete(onComplete)
|
||||
.id;
|
||||
}
|
||||
|
||||
public static int FadeText(TextMeshProUGUI text, float targetAlpha,
|
||||
float duration, System.Action onComplete = null,
|
||||
LeanTweenType easeType = LeanTweenType.easeInQuad)
|
||||
{
|
||||
return FadeText(text?.gameObject, text, null, targetAlpha, duration, 1f, onComplete, easeType);
|
||||
}
|
||||
|
||||
public static int FadeText(GameObject targetObject, TextMeshProUGUI mainText,
|
||||
TextMeshProUGUI ghostText, float targetAlpha, float duration,
|
||||
float ghostAlphaRatio, System.Action onComplete = null,
|
||||
LeanTweenType easeType = LeanTweenType.easeInQuad)
|
||||
{
|
||||
if (mainText == null || targetObject == null) return -1;
|
||||
|
||||
float startAlpha = mainText.color.a;
|
||||
LeanTween.cancel(targetObject);
|
||||
|
||||
return LeanTween.value(targetObject, startAlpha, targetAlpha, duration)
|
||||
.setEase(easeType)
|
||||
.setOnUpdate((float val) => {
|
||||
if (mainText != null)
|
||||
{
|
||||
Color c = mainText.color;
|
||||
c.a = val;
|
||||
mainText.color = c;
|
||||
}
|
||||
|
||||
if (ghostText != null)
|
||||
{
|
||||
Color gc = ghostText.color;
|
||||
gc.a = val * ghostAlphaRatio;
|
||||
ghostText.color = gc;
|
||||
}
|
||||
})
|
||||
.setOnComplete(onComplete)
|
||||
.id;
|
||||
}
|
||||
|
||||
public static int ScaleLocal(GameObject target, Vector3 targetScale,
|
||||
float duration, System.Action onComplete = null,
|
||||
LeanTweenType easeType = LeanTweenType.easeInOutQuad)
|
||||
{
|
||||
if (target == null) return -1;
|
||||
|
||||
LeanTween.cancel(target);
|
||||
return LeanTween.scale(target, targetScale, duration)
|
||||
.setEase(easeType)
|
||||
.setOnComplete(onComplete)
|
||||
.id;
|
||||
}
|
||||
|
||||
public static int MoveLocal(GameObject target, Vector3 targetPosition,
|
||||
float duration, System.Action onComplete = null,
|
||||
LeanTweenType easeType = LeanTweenType.easeInOutQuad)
|
||||
{
|
||||
if (target == null) return -1;
|
||||
|
||||
LeanTween.cancel(target);
|
||||
return LeanTween.moveLocal(target, targetPosition, duration)
|
||||
.setEase(easeType)
|
||||
.setOnComplete(onComplete)
|
||||
.id;
|
||||
}
|
||||
|
||||
public static int RotateLocal(GameObject target, Vector3 targetRotation,
|
||||
float duration, System.Action onComplete = null,
|
||||
LeanTweenType easeType = LeanTweenType.easeInOutQuad)
|
||||
{
|
||||
if (target == null) return -1;
|
||||
|
||||
LeanTween.cancel(target);
|
||||
return LeanTween.rotate(target, targetRotation, duration)
|
||||
.setEase(easeType)
|
||||
.setOnComplete(onComplete)
|
||||
.id;
|
||||
}
|
||||
|
||||
public static int MoveLocalWithCurve(GameObject target, Vector3 targetPosition,
|
||||
float duration, AnimationCurve curve, System.Action onComplete = null)
|
||||
{
|
||||
if (target == null) return -1;
|
||||
|
||||
LeanTween.cancel(target);
|
||||
return LeanTween.moveLocal(target, targetPosition, duration)
|
||||
.setEase(curve)
|
||||
.setOnComplete(onComplete)
|
||||
.id;
|
||||
}
|
||||
|
||||
public static int FadeVolume(AudioSource source, float targetVolume,
|
||||
float duration, System.Action onComplete = null,
|
||||
LeanTweenType easeType = LeanTweenType.easeInOutQuad)
|
||||
{
|
||||
if (source == null) return -1;
|
||||
|
||||
GameObject sourceObject = source.gameObject;
|
||||
LeanTween.cancel(sourceObject);
|
||||
|
||||
return LeanTween.value(sourceObject, source.volume, targetVolume, duration)
|
||||
.setEase(easeType)
|
||||
.setOnUpdate((float val) => {
|
||||
if (source != null)
|
||||
source.volume = val;
|
||||
})
|
||||
.setOnComplete(onComplete)
|
||||
.id;
|
||||
}
|
||||
|
||||
public static int FadeVolume(GameObject target, float targetVolume,
|
||||
float duration, System.Action onComplete = null,
|
||||
LeanTweenType easeType = LeanTweenType.linear)
|
||||
{
|
||||
if (target == null) return -1;
|
||||
|
||||
float startVolume = AudioListener.volume;
|
||||
LeanTween.cancel(target);
|
||||
|
||||
return LeanTween.value(target, startVolume, targetVolume, duration)
|
||||
.setEase(easeType)
|
||||
.setOnUpdate((float val) => {
|
||||
AudioListener.volume = val;
|
||||
})
|
||||
.setOnComplete(onComplete)
|
||||
.id;
|
||||
}
|
||||
|
||||
public static int FloatAnimation(GameObject target, Vector3 basePosition,
|
||||
float amplitude, float duration, float delay = 0f, Axis axis = Axis.Y)
|
||||
{
|
||||
if (target == null) return -1;
|
||||
|
||||
LeanTween.cancel(target);
|
||||
|
||||
return LeanTween.value(target, 0f, Mathf.PI * 2f, duration)
|
||||
.setDelay(delay)
|
||||
.setOnUpdate((float val) => {
|
||||
if (target != null)
|
||||
{
|
||||
Vector3 newPos = basePosition;
|
||||
float offset = Mathf.Sin(val) * amplitude;
|
||||
|
||||
switch (axis)
|
||||
{
|
||||
case Axis.X: newPos.x += offset; break;
|
||||
case Axis.Y: newPos.y += offset; break;
|
||||
case Axis.Z: newPos.z += offset; break;
|
||||
}
|
||||
|
||||
target.transform.localPosition = newPos;
|
||||
}
|
||||
})
|
||||
.setLoopClamp()
|
||||
.setEase(LeanTweenType.linear)
|
||||
.id;
|
||||
}
|
||||
|
||||
public static int RotateAround(GameObject target, Vector3 axis, float degreesPerSecond)
|
||||
{
|
||||
if (target == null || degreesPerSecond <= 0f) return -1;
|
||||
|
||||
float duration = 360f / degreesPerSecond;
|
||||
LeanTween.cancel(target);
|
||||
|
||||
return LeanTween.rotateAroundLocal(target, axis, 360f, duration)
|
||||
.setLoopClamp()
|
||||
.setEase(LeanTweenType.linear)
|
||||
.id;
|
||||
}
|
||||
|
||||
public static int AnimateMaterialFloat(GameObject target, Material material,
|
||||
int propertyID, float fromValue, float toValue, float duration,
|
||||
System.Action onComplete = null,
|
||||
LeanTweenType easeType = LeanTweenType.easeInOutQuad)
|
||||
{
|
||||
if (target == null || material == null) return -1;
|
||||
|
||||
LeanTween.cancel(target);
|
||||
|
||||
return LeanTween.value(target, fromValue, toValue, duration)
|
||||
.setEase(easeType)
|
||||
.setOnUpdate((float val) => {
|
||||
if (material != null)
|
||||
material.SetFloat(propertyID, val);
|
||||
})
|
||||
.setOnComplete(onComplete)
|
||||
.id;
|
||||
}
|
||||
|
||||
public static void CancelTween(ref int tweenId)
|
||||
{
|
||||
if (tweenId != -1)
|
||||
{
|
||||
LeanTween.cancel(tweenId);
|
||||
tweenId = -1;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public enum Axis
|
||||
{
|
||||
X, Y, Z
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f70df97fd94f86a4aa2cb00353624ba4
|
||||
@@ -0,0 +1,47 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.Core.Utils
|
||||
{
|
||||
public static class UnityExtensions
|
||||
{
|
||||
public static void SafeInvoke(this System.Action action)
|
||||
{
|
||||
action?.Invoke();
|
||||
}
|
||||
|
||||
public static void SafeInvoke<T>(this System.Action<T> action, T arg)
|
||||
{
|
||||
action?.Invoke(arg);
|
||||
}
|
||||
|
||||
public static Color WithAlpha(this Color color, float alpha)
|
||||
{
|
||||
color.a = alpha;
|
||||
return color;
|
||||
}
|
||||
|
||||
public static Vector3 WithY(this Vector3 vector, float y)
|
||||
{
|
||||
vector.y = y;
|
||||
return vector;
|
||||
}
|
||||
|
||||
public static Vector3 WithX(this Vector3 vector, float x)
|
||||
{
|
||||
vector.x = x;
|
||||
return vector;
|
||||
}
|
||||
|
||||
public static Vector3 WithZ(this Vector3 vector, float z)
|
||||
{
|
||||
vector.z = z;
|
||||
return vector;
|
||||
}
|
||||
|
||||
public static void CancelAllTweens(this GameObject gameObject)
|
||||
{
|
||||
if (gameObject != null)
|
||||
LeanTween.cancel(gameObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b5c8821940a7678458f26015ee167fdf
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 19e29f2b592fdb44da1490d3f1d8befe
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 494acbc99a38a1f4dbbe46579e1a4d2c
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,129 @@
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
using UnityEngine;
|
||||
using Unheimlich.Features.Player;
|
||||
|
||||
namespace Unheimlich.Dev
|
||||
{
|
||||
public class AudioSourceDemo : MonoBehaviour
|
||||
{
|
||||
[Header("Demo Settings")]
|
||||
[SerializeField] private AudioClip _clip;
|
||||
[SerializeField] private bool _playOnStart = true;
|
||||
[SerializeField] private bool _loop = true;
|
||||
[SerializeField] private float _volume = 0.5f;
|
||||
[SerializeField] private float _sourceMinDistance = 1f;
|
||||
[SerializeField] private float _sourceMaxDistance = 20;
|
||||
|
||||
[Header("Distance Culling")]
|
||||
[SerializeField] private bool _enableDistanceCulling = true;
|
||||
[SerializeField] private float _cullingDistance = 50f;
|
||||
[SerializeField] private float _fadeStartDistance = 40f;
|
||||
|
||||
private AudioSource _source;
|
||||
private bool _hasStarted = false;
|
||||
private float _savedTime = 0f;
|
||||
private bool _wasPlaying = false;
|
||||
private bool _isCulled = false;
|
||||
private Transform _player;
|
||||
|
||||
public float OriginalVolume => _volume;
|
||||
|
||||
public float GetDistanceFadeMultiplier(Vector3 listenerPosition)
|
||||
{
|
||||
if (!_enableDistanceCulling)
|
||||
return 1f;
|
||||
|
||||
float distance = Vector3.Distance(transform.position, listenerPosition);
|
||||
|
||||
if (distance > _cullingDistance)
|
||||
return 0f;
|
||||
|
||||
if (distance > _fadeStartDistance)
|
||||
{
|
||||
float fadeRange = _cullingDistance - _fadeStartDistance;
|
||||
float fadeAmount = (distance - _fadeStartDistance) / fadeRange;
|
||||
return 1f - Mathf.Clamp01(fadeAmount);
|
||||
}
|
||||
|
||||
return 1f;
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_source = GetComponent<AudioSource>();
|
||||
if (_source == null)
|
||||
_source = gameObject.AddComponent<AudioSource>();
|
||||
|
||||
_source.clip = _clip;
|
||||
_source.loop = _loop;
|
||||
_source.volume = _volume;
|
||||
_source.playOnAwake = false;
|
||||
_source.spatialBlend = 1f;
|
||||
_source.rolloffMode = AudioRolloffMode.Logarithmic;
|
||||
_source.minDistance = _sourceMinDistance;
|
||||
_source.maxDistance = _sourceMaxDistance;
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_wasPlaying && _savedTime > 0f)
|
||||
{
|
||||
_source.time = _savedTime;
|
||||
_source.Play();
|
||||
}
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (_playOnStart && _clip != null && !_hasStarted && !_source.isPlaying)
|
||||
{
|
||||
_source.Play();
|
||||
_hasStarted = true;
|
||||
}
|
||||
|
||||
PlayerController playerObj = PlayerController.Instance;
|
||||
if (playerObj != null)
|
||||
_player = playerObj.transform;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (_enableDistanceCulling && _player != null && _source != null)
|
||||
{
|
||||
float distance = Vector3.Distance(transform.position, _player.position);
|
||||
|
||||
if (distance > _cullingDistance)
|
||||
{
|
||||
if (!_isCulled)
|
||||
{
|
||||
_source.volume = 0f;
|
||||
_isCulled = true;
|
||||
}
|
||||
}
|
||||
else if (distance > _fadeStartDistance)
|
||||
{
|
||||
float fadeRange = _cullingDistance - _fadeStartDistance;
|
||||
float fadeAmount = (distance - _fadeStartDistance) / fadeRange;
|
||||
fadeAmount = Mathf.Clamp01(fadeAmount);
|
||||
_source.volume = _volume * (1f - fadeAmount);
|
||||
_isCulled = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_isCulled || _source.volume != _volume)
|
||||
{
|
||||
_source.volume = _volume;
|
||||
_isCulled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_wasPlaying = _source.isPlaying;
|
||||
_savedTime = _source.time;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f399329860533c14fba15187d7e340db
|
||||
@@ -0,0 +1,124 @@
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
using UnityEngine;
|
||||
using Unheimlich.Features.Player;
|
||||
using Unheimlich.Features.Diapason;
|
||||
using Unheimlich.Core.Audio;
|
||||
namespace Unheimlich.Dev
|
||||
{
|
||||
public class DiapasonDebugger : MonoBehaviour
|
||||
{
|
||||
[Header("Debug Display")]
|
||||
[SerializeField] private bool _showDebugInfo = true;
|
||||
[SerializeField] private bool _showGizmos = true;
|
||||
[SerializeField] private Color _listeningRangeColor = Color.cyan;
|
||||
[SerializeField] private Color _activeTargetColor = Color.green;
|
||||
|
||||
private DiapasonListeningHandlerV2 _listeningHandler;
|
||||
private AudioListenable[] _allListenables;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_listeningHandler = DiapasonListeningHandlerV2.Instance;
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
RefreshListenablesList();
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
if (!_showDebugInfo || _listeningHandler == null)
|
||||
return;
|
||||
|
||||
GUILayout.BeginArea(new Rect(10, 10, 300, 200));
|
||||
GUILayout.Box("Diapason System Debug");
|
||||
|
||||
GUILayout.Label($"Is Listening: {_listeningHandler.IsListening}");
|
||||
GUILayout.Label($"Is Concentrating: {_listeningHandler.IsConcentrating}");
|
||||
|
||||
if (_allListenables != null)
|
||||
GUILayout.Label($"Total Listenables: {_allListenables.Length}");
|
||||
|
||||
if (GUILayout.Button("Refresh Listenables List"))
|
||||
RefreshListenablesList();
|
||||
|
||||
if (GUILayout.Button("Toggle Debug Gizmos"))
|
||||
_showGizmos = !_showGizmos;
|
||||
|
||||
GUILayout.EndArea();
|
||||
}
|
||||
|
||||
private void RefreshListenablesList()
|
||||
{
|
||||
_allListenables = FindObjectsByType<AudioListenable>(FindObjectsSortMode.None);
|
||||
if (!_showDebugInfo)
|
||||
return;
|
||||
Debug.Log($"Found {_allListenables.Length} AudioListenable objects in scene");
|
||||
}
|
||||
|
||||
private void OnDrawGizmos()
|
||||
{
|
||||
if (!_showGizmos)
|
||||
return;
|
||||
|
||||
if (_allListenables == null)
|
||||
_allListenables = FindObjectsByType<AudioListenable>(FindObjectsSortMode.None);
|
||||
|
||||
foreach (AudioListenable listenable in _allListenables)
|
||||
{
|
||||
if (listenable == null)
|
||||
continue;
|
||||
|
||||
Gizmos.color = _listeningRangeColor;
|
||||
Gizmos.DrawIcon(listenable.transform.position, "sv_icon_dot3_pix16_gizmo", true);
|
||||
|
||||
Gizmos.color = new Color(_listeningRangeColor.r, _listeningRangeColor.g, _listeningRangeColor.b, 0.1f);
|
||||
Gizmos.DrawSphere(listenable.transform.position, 0.2f);
|
||||
}
|
||||
|
||||
if (_listeningHandler != null && _listeningHandler.IsListening)
|
||||
{
|
||||
Gizmos.color = _activeTargetColor;
|
||||
}
|
||||
}
|
||||
|
||||
[ContextMenu("List All Listenables")]
|
||||
private void ListAllListenables()
|
||||
{
|
||||
RefreshListenablesList();
|
||||
|
||||
Debug.Log("=== Audio Listenables in Scene ===");
|
||||
foreach (AudioListenable listenable in _allListenables)
|
||||
{
|
||||
if (listenable != null)
|
||||
Debug.Log($"- {listenable.gameObject.name} (Type: {listenable.GetType().Name})");
|
||||
}
|
||||
}
|
||||
|
||||
[ContextMenu("Enable All Listenables")]
|
||||
private void EnableAllListenables()
|
||||
{
|
||||
RefreshListenablesList();
|
||||
foreach (AudioListenable listenable in _allListenables)
|
||||
{
|
||||
if (listenable != null)
|
||||
listenable.enabled = true;
|
||||
}
|
||||
Debug.Log("Enabled all AudioListenable components");
|
||||
}
|
||||
|
||||
[ContextMenu("Disable All Listenables")]
|
||||
private void DisableAllListenables()
|
||||
{
|
||||
RefreshListenablesList();
|
||||
foreach (AudioListenable listenable in _allListenables)
|
||||
{
|
||||
if (listenable != null)
|
||||
listenable.enabled = false;
|
||||
}
|
||||
Debug.Log("Disabled all AudioListenable components");
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7093688fd39800645bbe00b6d9a3f27b
|
||||
@@ -0,0 +1,183 @@
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
using UnityEngine;
|
||||
using Unheimlich.Features.OctoArms;
|
||||
|
||||
namespace Unheimlich.Dev
|
||||
{
|
||||
|
||||
[ExecuteInEditMode]
|
||||
public class DiapasonPoseTweaker : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private OctoIkArmsImproved _armsSystem;
|
||||
// [SerializeField] private int _armIndex = 1;
|
||||
|
||||
[Header("Quick Presets")]
|
||||
[SerializeField] private bool _applyPreset1 = false;
|
||||
[SerializeField] private bool _applyPreset2 = false;
|
||||
[SerializeField] private bool _applyPreset3 = false;
|
||||
|
||||
[Header("Live Tweaking (Runtime Only)")]
|
||||
[SerializeField] private Vector3 _shoulderRotation = new Vector3(20, 0, -10);
|
||||
[SerializeField] private Vector3 _elbowRotation = new Vector3(0, 0, 0);
|
||||
[SerializeField] private Vector3 _wristRotation = new Vector3(0, 0, 90);
|
||||
|
||||
[Header("Preview")]
|
||||
[SerializeField] private bool _previewPose = false;
|
||||
|
||||
private ArmEquipment _equipment;
|
||||
private bool _wasPreviewActive = false;
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (!Application.isPlaying)
|
||||
return;
|
||||
|
||||
if (_armsSystem == null)
|
||||
return;
|
||||
|
||||
if (_equipment == null)
|
||||
{
|
||||
var armRigField = typeof(OctoIkArmsImproved).GetField("_rightArm",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
if (armRigField != null)
|
||||
{
|
||||
var armRig = armRigField.GetValue(_armsSystem);
|
||||
var equipmentField = armRig.GetType().GetField("Equipment");
|
||||
if (equipmentField != null)
|
||||
{
|
||||
_equipment = (ArmEquipment)equipmentField.GetValue(armRig);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_equipment == null)
|
||||
return;
|
||||
|
||||
if (_previewPose && _equipment.State == ArmState.Environmental)
|
||||
{
|
||||
ApplyRotations();
|
||||
_wasPreviewActive = true;
|
||||
}
|
||||
else if (_wasPreviewActive && !_previewPose)
|
||||
{
|
||||
ResetRotations();
|
||||
_wasPreviewActive = false;
|
||||
}
|
||||
|
||||
if (_equipment.State == ArmState.Equipped)
|
||||
{
|
||||
_equipment.ShoulderRotationOffset = _shoulderRotation;
|
||||
_equipment.ElbowRotationOffset = _elbowRotation;
|
||||
_equipment.WristRotationOffset = _wristRotation;
|
||||
}
|
||||
}
|
||||
|
||||
void OnValidate()
|
||||
{
|
||||
if (_applyPreset1)
|
||||
{
|
||||
_applyPreset1 = false;
|
||||
ApplyPreset1();
|
||||
}
|
||||
|
||||
if (_applyPreset2)
|
||||
{
|
||||
_applyPreset2 = false;
|
||||
ApplyPreset2();
|
||||
}
|
||||
|
||||
if (_applyPreset3)
|
||||
{
|
||||
_applyPreset3 = false;
|
||||
ApplyPreset3();
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyPreset1()
|
||||
{
|
||||
_shoulderRotation = new Vector3(30, 0, -15);
|
||||
_elbowRotation = new Vector3(0, 10, 0);
|
||||
_wristRotation = new Vector3(0, 0, 90);
|
||||
Debug.Log("[DiapasonPose] Applied Preset 1: Close to body, slight inward");
|
||||
}
|
||||
|
||||
private void ApplyPreset2()
|
||||
{
|
||||
_shoulderRotation = new Vector3(20, -10, -20);
|
||||
_elbowRotation = new Vector3(0, 0, 0);
|
||||
_wristRotation = new Vector3(0, -15, 90);
|
||||
Debug.Log("[DiapasonPose] Applied Preset 2: Further from left hand, angled");
|
||||
}
|
||||
|
||||
private void ApplyPreset3()
|
||||
{
|
||||
_shoulderRotation = new Vector3(15, 0, -25);
|
||||
_elbowRotation = new Vector3(0, 15, 0);
|
||||
_wristRotation = new Vector3(0, 0, 85);
|
||||
Debug.Log("[DiapasonPose] Applied Preset 3: Natural grip, centered");
|
||||
}
|
||||
|
||||
private void ApplyRotations()
|
||||
{
|
||||
if (_equipment.ShoulderBone != null)
|
||||
{
|
||||
_equipment.ShoulderBone.localRotation = _equipment.OriginalShoulderRotation * Quaternion.Euler(_shoulderRotation);
|
||||
}
|
||||
|
||||
if (_equipment.ElbowBone != null)
|
||||
{
|
||||
_equipment.ElbowBone.localRotation = _equipment.OriginalElbowRotation * Quaternion.Euler(_elbowRotation);
|
||||
}
|
||||
|
||||
if (_equipment.WristBone != null)
|
||||
{
|
||||
_equipment.WristBone.localRotation = _equipment.OriginalWristRotation * Quaternion.Euler(_wristRotation);
|
||||
}
|
||||
}
|
||||
|
||||
private void ResetRotations()
|
||||
{
|
||||
if (_equipment.ShoulderBone != null)
|
||||
{
|
||||
_equipment.ShoulderBone.localRotation = _equipment.OriginalShoulderRotation;
|
||||
}
|
||||
|
||||
if (_equipment.ElbowBone != null)
|
||||
{
|
||||
_equipment.ElbowBone.localRotation = _equipment.OriginalElbowRotation;
|
||||
}
|
||||
|
||||
if (_equipment.WristBone != null)
|
||||
{
|
||||
_equipment.WristBone.localRotation = _equipment.OriginalWristRotation;
|
||||
}
|
||||
}
|
||||
|
||||
void OnGUI()
|
||||
{
|
||||
if (!Application.isPlaying)
|
||||
return;
|
||||
|
||||
GUILayout.BeginArea(new Rect(10, 150, 350, 250));
|
||||
GUILayout.Label("=== Diapason Pose Tweaker ===");
|
||||
|
||||
GUILayout.Label($"Shoulder: {_shoulderRotation}");
|
||||
GUILayout.Label($"Elbow: {_elbowRotation}");
|
||||
GUILayout.Label($"Wrist: {_wristRotation}");
|
||||
|
||||
GUILayout.Space(10);
|
||||
GUILayout.Label("Tips:");
|
||||
GUILayout.Label("• Shoulder X: + = down, - = up");
|
||||
GUILayout.Label("• Shoulder Y: + = inward, - = outward");
|
||||
GUILayout.Label("• Shoulder Z: + = twist out, - = twist in");
|
||||
GUILayout.Label("• Elbow Y: + = bend out, - = bend in");
|
||||
GUILayout.Label("• Wrist Z: rotation du diapason");
|
||||
|
||||
GUILayout.EndArea();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8d1a1f500877b404fa6d23dff3bfa8c8
|
||||
@@ -0,0 +1,54 @@
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
using UnityEngine;
|
||||
using Unheimlich.Features.Portals;
|
||||
|
||||
namespace Unheimlich.Dev
|
||||
{
|
||||
public class PortalAudioDebugger : MonoBehaviour
|
||||
{
|
||||
private Portal _portal;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_portal = GetComponent<Portal>();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_portal != null)
|
||||
{
|
||||
_portal.RoomLoaded += OnRoomLoadedDebug;
|
||||
_portal.RoomUnloaded += OnRoomUnloadedDebug;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_portal != null)
|
||||
{
|
||||
_portal.RoomLoaded -= OnRoomLoadedDebug;
|
||||
_portal.RoomUnloaded -= OnRoomUnloadedDebug;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRoomLoadedDebug()
|
||||
{
|
||||
Debug.Log($"<color=green>[PortalDebug] RoomLoaded event fired on {gameObject.name}</color>");
|
||||
|
||||
if (_portal.LoadedRoomInstance != null)
|
||||
{
|
||||
AudioSource[] sources = _portal.LoadedRoomInstance.GetComponentsInChildren<AudioSource>();
|
||||
Debug.Log($"<color=green>[PortalDebug] Found {sources.Length} AudioSources in loaded room:</color>");
|
||||
|
||||
foreach (var source in sources)
|
||||
Debug.Log($" - {source.name} | Playing: {source.isPlaying} | Clip: {(source.clip != null ? source.clip.name : "NULL")}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRoomUnloadedDebug()
|
||||
{
|
||||
Debug.Log($"<color=red>[PortalDebug] RoomUnloaded event fired on {gameObject.name}</color>");
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 897784c22112f5f48af4313022397d8e
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e0aa54a362183cc438127eed92b8ec3d
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,49 @@
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using Unheimlich.Features.Diapason;
|
||||
|
||||
namespace Unheimlich.Dev
|
||||
{
|
||||
|
||||
public class DiapasonEquipmentTester : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private DiapasonEquipmentManager _equipmentManager;
|
||||
|
||||
[Header("Input")]
|
||||
[SerializeField] private Key _equipKey = Key.E;
|
||||
[SerializeField] private Key _unequipKey = Key.R;
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (_equipmentManager == null)
|
||||
return;
|
||||
|
||||
if (Keyboard.current[_equipKey].wasPressedThisFrame)
|
||||
{
|
||||
_equipmentManager.EquipDiapason();
|
||||
}
|
||||
|
||||
if (Keyboard.current[_unequipKey].wasPressedThisFrame)
|
||||
{
|
||||
_equipmentManager.UnequipDiapason();
|
||||
}
|
||||
}
|
||||
|
||||
void OnGUI()
|
||||
{
|
||||
GUILayout.BeginArea(new Rect(10, 10, 350, 150));
|
||||
GUILayout.Label("=== Diapason Equipment System ===");
|
||||
GUILayout.Label($"Press {_equipKey} to Equip Diapason");
|
||||
GUILayout.Label($"Press {_unequipKey} to Unequip Diapason");
|
||||
GUILayout.Label("");
|
||||
GUILayout.Label("When equipped:");
|
||||
GUILayout.Label("• Move LEFT hand to touch AudioListenable");
|
||||
GUILayout.Label("• LEFT CLICK to tap and activate");
|
||||
GUILayout.EndArea();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9279e0fb73118d14ba6f2cf05170a660
|
||||
@@ -0,0 +1,67 @@
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
using UnityEngine;
|
||||
using OctoCore.Localization;
|
||||
using UnityEngine.InputSystem;
|
||||
|
||||
namespace Unheimlich.Dev
|
||||
{
|
||||
/// <summary>
|
||||
/// Simple debug script to switch language at runtime.
|
||||
/// Attach to any GameObject and press keys to change language.
|
||||
/// </summary>
|
||||
public class LanguageSwitcher : MonoBehaviour
|
||||
{
|
||||
[Header("Info")]
|
||||
[Tooltip("Current language")]
|
||||
[SerializeField] private OctoLanguages _currentLanguage;
|
||||
|
||||
void Start()
|
||||
{
|
||||
if (OctoCore.OctoCore.Instance != null && OctoCore.OctoCore.Instance.Localization != null)
|
||||
{
|
||||
_currentLanguage = OctoCore.OctoCore.Instance.Localization.WrittenLanguage;
|
||||
UnityEngine.Debug.Log($"[LanguageSwitcher] Current language: {_currentLanguage}");
|
||||
UnityEngine.Debug.Log($"[LanguageSwitcher] Press F1 for English, F2 for French");
|
||||
}
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (OctoCore.OctoCore.Instance == null || OctoCore.OctoCore.Instance.Localization == null)
|
||||
return;
|
||||
|
||||
if (Keyboard.current == null)
|
||||
return;
|
||||
|
||||
if (Keyboard.current.f1Key.wasPressedThisFrame)
|
||||
{
|
||||
SetLanguage(OctoLanguages.English);
|
||||
}
|
||||
else if (Keyboard.current.f2Key.wasPressedThisFrame)
|
||||
{
|
||||
SetLanguage(OctoLanguages.French);
|
||||
}
|
||||
}
|
||||
|
||||
private void SetLanguage(OctoLanguages language)
|
||||
{
|
||||
OctoCore.OctoCore.Instance.Localization.SetLanguageWritten(language);
|
||||
_currentLanguage = language;
|
||||
|
||||
UnityEngine.Debug.Log($"<color=green>[LanguageSwitcher]</color> Language changed to: <color=yellow>{language}</color>");
|
||||
}
|
||||
|
||||
void OnGUI()
|
||||
{
|
||||
// Display current language in top-left corner
|
||||
if (OctoCore.OctoCore.Instance != null && OctoCore.OctoCore.Instance.Localization != null)
|
||||
{
|
||||
GUI.color = Color.yellow;
|
||||
GUI.Label(new Rect(10, 10, 300, 30), $"Language: {_currentLanguage}");
|
||||
GUI.Label(new Rect(10, 30, 300, 30), $"[F1] EN | [F2] FR");
|
||||
GUI.color = Color.white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 86180434dad93e344b4a5229575d4757
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4c908d265525e8743b3eacda7f1ad0f5
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,106 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using Unheimlich.Core.Audio;
|
||||
|
||||
namespace Unheimlich.Editor
|
||||
{
|
||||
[CustomEditor(typeof(AudioListenable))]
|
||||
public class AudioListenableEditor : UnityEditor.Editor
|
||||
{
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
DrawDefaultInspector();
|
||||
|
||||
AudioListenable listenable = (AudioListenable)target;
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
EditorGUILayout.LabelField("Quick Setup", EditorStyles.boldLabel);
|
||||
|
||||
if (GUILayout.Button("Setup as Wall"))
|
||||
SetupAsType(listenable, AudioEmitterType.Wall, 200f, 20f, true);
|
||||
|
||||
if (GUILayout.Button("Setup as Metal Object"))
|
||||
SetupAsType(listenable, AudioEmitterType.Metal, 800f, 100f, false);
|
||||
|
||||
if (GUILayout.Button("Setup as Wood Object"))
|
||||
SetupAsType(listenable, AudioEmitterType.Wood, 300f, 50f, false);
|
||||
|
||||
if (GUILayout.Button("Setup as Glass"))
|
||||
SetupAsType(listenable, AudioEmitterType.Glass, 1200f, 150f, false);
|
||||
|
||||
if (GUILayout.Button("Setup as Mechanical"))
|
||||
SetupAsType(listenable, AudioEmitterType.Mechanical, 500f, 80f, false);
|
||||
|
||||
EditorGUILayout.Space(5);
|
||||
|
||||
if (GUILayout.Button("Test Sound (Play Mode Only)", GUILayout.Height(30)))
|
||||
{
|
||||
if (Application.isPlaying)
|
||||
{
|
||||
listenable.StartListening(Camera.main != null ? Camera.main.transform.position : Vector3.zero);
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorGUILayout.HelpBox("Enter Play Mode to test sounds", MessageType.Info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SetupAsType(AudioListenable listenable, AudioEmitterType type, float baseFreq, float freqVar, bool isGeneric)
|
||||
{
|
||||
SerializedObject so = new SerializedObject(listenable);
|
||||
|
||||
so.FindProperty("_emitterType").enumValueIndex = (int)type;
|
||||
so.FindProperty("_useProceduralAudio").boolValue = true;
|
||||
so.FindProperty("_baseFrequency").floatValue = baseFreq;
|
||||
so.FindProperty("_frequencyVariation").floatValue = freqVar;
|
||||
|
||||
if (isGeneric)
|
||||
{
|
||||
so.FindProperty("_volumeMin").floatValue = 0.2f;
|
||||
so.FindProperty("_volumeMax").floatValue = 0.4f;
|
||||
}
|
||||
|
||||
so.ApplyModifiedProperties();
|
||||
EditorUtility.SetDirty(listenable);
|
||||
}
|
||||
}
|
||||
|
||||
[CanEditMultipleObjects]
|
||||
[CustomEditor(typeof(Transform))]
|
||||
public class TransformEditorExtension : UnityEditor.Editor
|
||||
{
|
||||
private UnityEditor.Editor defaultEditor;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
defaultEditor = CreateEditor(targets, System.Type.GetType("UnityEditor.TransformInspector, UnityEditor"));
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (defaultEditor != null)
|
||||
DestroyImmediate(defaultEditor);
|
||||
}
|
||||
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
if (defaultEditor != null)
|
||||
defaultEditor.OnInspectorGUI();
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
if (targets.Length == 1)
|
||||
{
|
||||
Transform t = (Transform)target;
|
||||
if (t.GetComponent<AudioListenable>() == null)
|
||||
{
|
||||
if (GUILayout.Button("Add Audio Listenable", GUILayout.Height(25)))
|
||||
{
|
||||
Undo.AddComponent<AudioListenable>(t.gameObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b606ceac9567f9746b716e63b0e644ee
|
||||
@@ -0,0 +1,63 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.Audio
|
||||
{
|
||||
public class AudioSourceInspector : MonoBehaviour
|
||||
{
|
||||
[Header("Debug Settings")]
|
||||
[SerializeField] private float _logInterval = 1f;
|
||||
|
||||
private AudioSource _source;
|
||||
private float _lastLogTime;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_source = GetComponent<AudioSource>();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (_source == null) return;
|
||||
|
||||
if (Time.time - _lastLogTime >= _logInterval)
|
||||
{
|
||||
LogAudioSourceState();
|
||||
_lastLogTime = Time.time;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
Debug.Log($"<color=cyan>[AudioInspector] OnEnable called on {gameObject.name}</color>");
|
||||
if (_source != null)
|
||||
LogAudioSourceState();
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
Debug.Log($"<color=magenta>[AudioInspector] OnDisable called on {gameObject.name}</color>");
|
||||
if (_source != null)
|
||||
LogAudioSourceState();
|
||||
}
|
||||
|
||||
private void LogAudioSourceState()
|
||||
{
|
||||
if (_source == null)
|
||||
{
|
||||
Debug.LogWarning($"[AudioInspector] No AudioSource on {gameObject.name}");
|
||||
return;
|
||||
}
|
||||
|
||||
string clipName = _source.clip != null ? _source.clip.name : "NULL";
|
||||
Debug.Log($"<color=yellow>[AudioInspector] {gameObject.name} | " +
|
||||
$"Clip: {clipName} | " +
|
||||
$"Time: {_source.time:F2}s | " +
|
||||
$"isPlaying: {_source.isPlaying} | " +
|
||||
$"enabled: {_source.enabled} | " +
|
||||
$"volume: {_source.volume:F2} | " +
|
||||
$"mute: {_source.mute} | " +
|
||||
$"loop: {_source.loop} | " +
|
||||
$"Active: {gameObject.activeInHierarchy}</color>");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 65fea0a39ddb811419ec546ea98e5917
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 51d3ce25787badd429148924ba0fdaca
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6712b96ee1ccf81428cd47e78893587a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,84 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using System.IO;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts animation clips embedded in a FBX into individual .anim files
|
||||
/// in the asset's Animations/ folder.
|
||||
/// </summary>
|
||||
public static class AnimationExtractor
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads all animation clips from the FBX and saves each as a .anim file
|
||||
/// in animationsDir. Skips clips that already exist (idempotent).
|
||||
/// </summary>
|
||||
public static void ExtractClips(string fbxAssetPath, string animationsDir)
|
||||
{
|
||||
if (!AssetDatabase.IsValidFolder(animationsDir))
|
||||
{
|
||||
Debug.LogWarning($"[FBXImporter] Animations folder not found: {animationsDir}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Load all assets embedded in the FBX
|
||||
var allAssets = AssetDatabase.LoadAllAssetsAtPath(fbxAssetPath);
|
||||
int count = 0;
|
||||
|
||||
foreach (var asset in allAssets)
|
||||
{
|
||||
if (!(asset is AnimationClip clip)) continue;
|
||||
|
||||
// Skip the "preview" take Unity adds internally
|
||||
if (clip.name.StartsWith("__preview__")) continue;
|
||||
|
||||
string clipName = SanitizeClipName(clip.name);
|
||||
string clipPath = $"{animationsDir}/{clipName}.anim";
|
||||
|
||||
// Skip if already extracted
|
||||
if (AssetDatabase.LoadAssetAtPath<AnimationClip>(clipPath) != null)
|
||||
{
|
||||
Debug.Log($"[FBXImporter] Clip already exists, skipped: {clipName}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Duplicate the clip as a standalone asset
|
||||
var extracted = Object.Instantiate(clip);
|
||||
extracted.name = clipName;
|
||||
AssetDatabase.CreateAsset(extracted, clipPath);
|
||||
count++;
|
||||
|
||||
Debug.Log($"[FBXImporter] Extracted clip → {clipPath}");
|
||||
}
|
||||
|
||||
if (count > 0)
|
||||
{
|
||||
AssetDatabase.SaveAssets();
|
||||
Debug.Log($"[FBXImporter] {count} clip(s) extracted to {animationsDir}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.Log($"[FBXImporter] No new animation clips found in {Path.GetFileName(fbxAssetPath)}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sanitizes a clip name for use as a filename.
|
||||
/// "Take 001" → "Take_001"
|
||||
/// </summary>
|
||||
static string SanitizeClipName(string name)
|
||||
{
|
||||
return name.Replace(" ", "_")
|
||||
.Replace("/", "_")
|
||||
.Replace("\\", "_")
|
||||
.Replace(":", "_")
|
||||
.Replace("|", "_")
|
||||
.Replace("*", "_")
|
||||
.Replace("?", "_")
|
||||
.Replace("\"", "_")
|
||||
.Replace("<", "_")
|
||||
.Replace(">", "_");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0ec504778d5233d40a1b99a372f7eb3e
|
||||
@@ -0,0 +1,94 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Animations;
|
||||
using System.IO;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an AnimatorController with one state per extracted animation clip,
|
||||
/// then assigns it to the Animator component on the prefab.
|
||||
/// This allows previewing animations in the editor without manual setup.
|
||||
/// </summary>
|
||||
public static class AnimatorControllerBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an AnimatorController in animationsDir, adds one state per .anim clip found,
|
||||
/// then assigns the controller to the Animator on the prefab at prefabPath.
|
||||
/// </summary>
|
||||
public static void CreateAndAssign(string animationsDir, string prefabPath, string assetName)
|
||||
{
|
||||
if (!AssetDatabase.IsValidFolder(animationsDir))
|
||||
{
|
||||
Debug.LogWarning($"[FBXImporter] Animations folder not found: {animationsDir}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Find all extracted .anim clips in Animations/
|
||||
var clipGuids = AssetDatabase.FindAssets("t:AnimationClip", new[] { animationsDir });
|
||||
if (clipGuids.Length == 0)
|
||||
{
|
||||
Debug.Log($"[FBXImporter] No clips found in {animationsDir} — skipping controller creation");
|
||||
return;
|
||||
}
|
||||
|
||||
var clips = new List<AnimationClip>();
|
||||
foreach (var guid in clipGuids)
|
||||
{
|
||||
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(
|
||||
AssetDatabase.GUIDToAssetPath(guid));
|
||||
if (clip != null) clips.Add(clip);
|
||||
}
|
||||
|
||||
// Create the controller
|
||||
string controllerPath = $"{animationsDir}/{assetName}.controller";
|
||||
var controller = AnimatorController.CreateAnimatorControllerAtPath(controllerPath);
|
||||
|
||||
// Add one state per clip
|
||||
var rootStateMachine = controller.layers[0].stateMachine;
|
||||
foreach (var clip in clips)
|
||||
{
|
||||
var state = rootStateMachine.AddState(clip.name);
|
||||
state.motion = clip;
|
||||
Debug.Log($"[FBXImporter] Controller state added: {clip.name}");
|
||||
}
|
||||
|
||||
// Set the first clip as default state
|
||||
if (clips.Count > 0)
|
||||
rootStateMachine.defaultState = rootStateMachine.states[0].state;
|
||||
|
||||
AssetDatabase.SaveAssets();
|
||||
Debug.Log($"[FBXImporter] AnimatorController created → {controllerPath}");
|
||||
|
||||
// Assign controller to the prefab
|
||||
AssignToPrefab(prefabPath, controller);
|
||||
}
|
||||
|
||||
static void AssignToPrefab(string prefabPath, AnimatorController controller)
|
||||
{
|
||||
var prefabAsset = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
|
||||
if (prefabAsset == null)
|
||||
{
|
||||
Debug.LogWarning($"[FBXImporter] Prefab not found at: {prefabPath}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Edit the prefab
|
||||
var prefabInstance = PrefabUtility.LoadPrefabContents(prefabPath);
|
||||
|
||||
// Get or add Animator component
|
||||
var animator = prefabInstance.GetComponent<Animator>();
|
||||
if (animator == null)
|
||||
animator = prefabInstance.AddComponent<Animator>();
|
||||
|
||||
animator.runtimeAnimatorController = controller;
|
||||
|
||||
// Save prefab changes
|
||||
PrefabUtility.SaveAsPrefabAsset(prefabInstance, prefabPath);
|
||||
PrefabUtility.UnloadPrefabContents(prefabInstance);
|
||||
|
||||
Debug.Log($"[FBXImporter] AnimatorController assigned to prefab: {Path.GetFileName(prefabPath)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5d86102882208454999c396da82b6886
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace FBXImporter
|
||||
{
|
||||
public class AssetFolders
|
||||
{
|
||||
public string Root { get; set; }
|
||||
public string Sources { get; set; }
|
||||
public string Textures { get; set; }
|
||||
public string Materials { get; set; }
|
||||
public string Prefabs { get; set; }
|
||||
public string Animations { get; set; }
|
||||
public string Audio { get; set; }
|
||||
public string VFX { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d94b3203f22e53f4ebcd1f0817c5987a
|
||||
@@ -0,0 +1,35 @@
|
||||
using UnityEditor;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Automatically removes a FBX from the registry when it's deleted from the project.
|
||||
///
|
||||
/// IMPORTANT: We resolve the GUID *before* returning DidNotDelete so Unity hasn't
|
||||
/// touched the file yet — AssetPathToGUID is still valid at this point.
|
||||
/// We use UnregisterByGuid to bypass a second GUID lookup after deletion starts,
|
||||
/// which would return an empty string and silently fail.
|
||||
/// </summary>
|
||||
public class FBXDeletionWatcher : UnityEditor.AssetModificationProcessor
|
||||
{
|
||||
static AssetDeleteResult OnWillDeleteAsset(string path, RemoveAssetOptions options)
|
||||
{
|
||||
if (!path.EndsWith(".fbx", System.StringComparison.OrdinalIgnoreCase) &&
|
||||
!path.EndsWith(".obj", System.StringComparison.OrdinalIgnoreCase))
|
||||
return AssetDeleteResult.DidNotDelete;
|
||||
|
||||
// Resolve GUID now, while the file still exists
|
||||
string guid = AssetDatabase.AssetPathToGUID(path);
|
||||
|
||||
if (!string.IsNullOrEmpty(guid))
|
||||
{
|
||||
// Use UnregisterByGuid — avoids a second AssetPathToGUID call
|
||||
// inside Unregister() which may fail once deletion is in progress
|
||||
ProcessedFBXRegistry.UnregisterByGuid(guid, System.IO.Path.GetFileName(path));
|
||||
}
|
||||
|
||||
// Always let Unity handle the actual deletion
|
||||
return AssetDeleteResult.DidNotDelete;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 689b81497b9e3a444965cbdf9a0260a2
|
||||
@@ -0,0 +1,186 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using System.IO;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
public static class FBXProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Full pipeline for a single FBX:
|
||||
/// 1. Detect source (Blender/Maya)
|
||||
/// 2. Create folder structure
|
||||
/// 3. Configure ModelImporter (rig type, materials)
|
||||
/// 4. Force reimport
|
||||
/// 5. Connect PBR textures → create .mat files
|
||||
/// 6. Extract animation clips → Animations/
|
||||
/// 7. Generate prefab(s) — one per mesh if multi-mesh FBX
|
||||
/// 8. Create AnimatorController + assign to prefab
|
||||
/// </summary>
|
||||
public static void Process(string fbxAssetPath,
|
||||
ModelImporterAnimationType animationType = ModelImporterAnimationType.Generic,
|
||||
string rootNode = "",
|
||||
string originalSourceFolder = "")
|
||||
{
|
||||
if ((!fbxAssetPath.EndsWith(".fbx", System.StringComparison.OrdinalIgnoreCase) &&
|
||||
!fbxAssetPath.EndsWith(".obj", System.StringComparison.OrdinalIgnoreCase)) ||
|
||||
!fbxAssetPath.StartsWith("Assets/"))
|
||||
{
|
||||
Debug.LogWarning($"[FBXImporter] Skipped: {fbxAssetPath}");
|
||||
return;
|
||||
}
|
||||
|
||||
Debug.Log($"[FBXImporter] ── Processing {Path.GetFileName(fbxAssetPath)} ──");
|
||||
|
||||
var source = FBXSourceDetector.Detect(fbxAssetPath);
|
||||
var folders = FolderSetup.Resolve(fbxAssetPath);
|
||||
bool withAnims = animationType != ModelImporterAnimationType.None;
|
||||
|
||||
FolderSetup.Create(
|
||||
Path.GetDirectoryName(folders.Root),
|
||||
Path.GetFileName(folders.Root),
|
||||
withAnims
|
||||
);
|
||||
folders = FolderSetup.Resolve(fbxAssetPath);
|
||||
|
||||
// Configure importer
|
||||
var importer = AssetImporter.GetAtPath(fbxAssetPath) as ModelImporter;
|
||||
if (importer != null)
|
||||
{
|
||||
ConfigureImporter(importer, source, animationType, rootNode);
|
||||
importer.SaveAndReimport();
|
||||
}
|
||||
|
||||
// Load model
|
||||
var fbxAsset = AssetDatabase.LoadAssetAtPath<GameObject>(fbxAssetPath);
|
||||
if (fbxAsset == null)
|
||||
{
|
||||
Debug.LogError($"[FBXImporter] Could not load: {fbxAssetPath}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Connect PBR textures
|
||||
ProcessMaterials(fbxAsset, folders, source);
|
||||
|
||||
// Extract animation clips
|
||||
if (withAnims && !string.IsNullOrEmpty(folders.Animations))
|
||||
AnimationExtractor.ExtractClips(fbxAssetPath, folders.Animations);
|
||||
|
||||
// Generate prefab(s)
|
||||
string assetName = Path.GetFileNameWithoutExtension(fbxAssetPath);
|
||||
string prefabPath = $"{folders.Prefabs}/{assetName}.prefab";
|
||||
PrefabBuilder.CreatePrefabs(fbxAsset, folders, assetName);
|
||||
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
// Create AnimatorController with clips and assign to prefab
|
||||
if (withAnims && !string.IsNullOrEmpty(folders.Animations))
|
||||
AnimatorControllerBuilder.CreateAndAssign(folders.Animations, prefabPath, assetName);
|
||||
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
Debug.Log($"[FBXImporter] ── Done: {assetName} ──");
|
||||
}
|
||||
|
||||
// ── Importer settings ─────────────────────────────────────────────────────
|
||||
|
||||
static void ConfigureImporter(ModelImporter importer, FBXSource source,
|
||||
ModelImporterAnimationType animType, string rootNode)
|
||||
{
|
||||
importer.importCameras = false;
|
||||
importer.importLights = false;
|
||||
importer.importNormals = ModelImporterNormals.Import;
|
||||
importer.importTangents = ModelImporterTangents.CalculateMikk;
|
||||
importer.importBlendShapes = true;
|
||||
importer.importVisibility = true;
|
||||
importer.useFileScale = true;
|
||||
importer.globalScale = 1f;
|
||||
|
||||
importer.materialImportMode = ModelImporterMaterialImportMode.ImportViaMaterialDescription;
|
||||
importer.materialLocation = ModelImporterMaterialLocation.External;
|
||||
importer.materialName = ModelImporterMaterialName.BasedOnMaterialName;
|
||||
importer.materialSearch = ModelImporterMaterialSearch.Local;
|
||||
|
||||
importer.animationType = animType;
|
||||
if (!string.IsNullOrWhiteSpace(rootNode))
|
||||
importer.motionNodeName = rootNode;
|
||||
}
|
||||
|
||||
// ── Materials ─────────────────────────────────────────────────────────────
|
||||
|
||||
static void ProcessMaterials(GameObject root, AssetFolders folders, FBXSource source)
|
||||
{
|
||||
foreach (var renderer in root.GetComponentsInChildren<Renderer>(true))
|
||||
{
|
||||
var mats = renderer.sharedMaterials;
|
||||
bool changed = false;
|
||||
|
||||
for (int i = 0; i < mats.Length; i++)
|
||||
{
|
||||
var processed = ProcessMaterial(mats[i], folders, source);
|
||||
if (processed != null && processed != mats[i])
|
||||
{
|
||||
mats[i] = processed;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) renderer.sharedMaterials = mats;
|
||||
}
|
||||
}
|
||||
|
||||
static Material ProcessMaterial(Material mat, AssetFolders folders, FBXSource source)
|
||||
{
|
||||
if (mat == null) return null;
|
||||
|
||||
string cleanName = TextureMatcher.CleanMaterialName(mat.name, source);
|
||||
string matPath = $"{folders.Materials}/{cleanName}.mat";
|
||||
|
||||
Material existing = AssetDatabase.LoadAssetAtPath<Material>(matPath);
|
||||
Material target = existing ?? new Material(ShaderDetector.GetLitShader());
|
||||
|
||||
ConnectPBRTextures(target, folders.Textures, cleanName);
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
AssetDatabase.CreateAsset(target, matPath);
|
||||
Debug.Log($"[FBXImporter] Material created → {matPath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorUtility.SetDirty(target);
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
static void ConnectPBRTextures(Material mat, string textureDir, string baseName)
|
||||
{
|
||||
foreach (var slot in TextureMatcher.SlotKeywords.Keys)
|
||||
{
|
||||
var tex = TextureMatcher.FindForSlot(textureDir, baseName, slot);
|
||||
if (tex == null) continue;
|
||||
|
||||
string pipelineSlot = ShaderDetector.GetSlotName(slot);
|
||||
mat.SetTexture(pipelineSlot, tex);
|
||||
|
||||
switch (slot)
|
||||
{
|
||||
case "_BumpMap":
|
||||
mat.EnableKeyword("_NORMALMAP");
|
||||
break;
|
||||
case "_EmissionMap":
|
||||
mat.EnableKeyword("_EMISSION");
|
||||
mat.SetColor("_EmissionColor", Color.white);
|
||||
mat.globalIlluminationFlags = MaterialGlobalIlluminationFlags.BakedEmissive;
|
||||
break;
|
||||
case "_ParallaxMap":
|
||||
mat.EnableKeyword("_PARALLAXMAP");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: eea47f2dacccac441aa4a6806412a43a
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
public enum FBXSource { Unknown, Blender, Maya }
|
||||
|
||||
public static class FBXSourceDetector
|
||||
{
|
||||
public static FBXSource Detect(string assetPath)
|
||||
{
|
||||
if (assetPath.Contains("/BlenderAssets/")) return FBXSource.Blender;
|
||||
if (assetPath.Contains("/MayaAssets/")) return FBXSource.Maya;
|
||||
|
||||
string fullPath = Path.GetFullPath(assetPath);
|
||||
if (!File.Exists(fullPath)) return FBXSource.Unknown;
|
||||
|
||||
try
|
||||
{
|
||||
byte[] buffer = new byte[2000];
|
||||
using (var fs = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read))
|
||||
fs.Read(buffer, 0, buffer.Length);
|
||||
|
||||
string header = Encoding.ASCII.GetString(buffer);
|
||||
if (header.Contains("Blender")) return FBXSource.Blender;
|
||||
if (header.Contains("Maya")) return FBXSource.Maya;
|
||||
if (header.Contains("FBX SDK")) return FBXSource.Maya;
|
||||
if (header.Contains("Autodesk")) return FBXSource.Maya;
|
||||
}
|
||||
catch { }
|
||||
|
||||
return FBXSource.Unknown;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 330be20566a6f0249bea3963ca032e4f
|
||||
@@ -0,0 +1,54 @@
|
||||
using UnityEditor;
|
||||
using System.IO;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
public static class FolderSetup
|
||||
{
|
||||
static readonly string[] RequiredFolders = { "Sources", "Textures", "Materials", "Prefabs", "Audio", "VFX" };
|
||||
|
||||
public static AssetFolders Create(string parentPath, string assetName, bool withAnimations = false)
|
||||
{
|
||||
string root = $"{parentPath}/{assetName}";
|
||||
EnsureFolder(parentPath, assetName);
|
||||
foreach (var folder in RequiredFolders)
|
||||
EnsureFolder(root, folder);
|
||||
|
||||
string animPath = $"{root}/Animations";
|
||||
if (withAnimations) EnsureFolder(root, "Animations");
|
||||
|
||||
AssetDatabase.Refresh();
|
||||
return BuildFolders(root, withAnimations ? animPath : null);
|
||||
}
|
||||
|
||||
public static AssetFolders Resolve(string fbxAssetPath)
|
||||
{
|
||||
string dir = Path.GetDirectoryName(fbxAssetPath).Replace("\\", "/");
|
||||
string root = dir.EndsWith("/Sources")
|
||||
? Path.GetDirectoryName(dir).Replace("\\", "/")
|
||||
: dir;
|
||||
|
||||
string animPath = $"{root}/Animations";
|
||||
return BuildFolders(root, AssetDatabase.IsValidFolder(animPath) ? animPath : null);
|
||||
}
|
||||
|
||||
static AssetFolders BuildFolders(string root, string animPath) => new AssetFolders
|
||||
{
|
||||
Root = root,
|
||||
Sources = $"{root}/Sources",
|
||||
Textures = $"{root}/Textures",
|
||||
Materials = $"{root}/Materials",
|
||||
Prefabs = $"{root}/Prefabs",
|
||||
Animations = animPath,
|
||||
Audio = $"{root}/Audio",
|
||||
VFX = $"{root}/VFX",
|
||||
};
|
||||
|
||||
static void EnsureFolder(string parent, string child)
|
||||
{
|
||||
string path = $"{parent}/{child}";
|
||||
if (!AssetDatabase.IsValidFolder(path))
|
||||
AssetDatabase.CreateFolder(parent, child);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b2256f5b9c45ebc48bbfc702f7b7177e
|
||||
@@ -0,0 +1,90 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
public static class PrefabBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a single prefab from the imported FBX root GameObject.
|
||||
/// Adds a LODGroup automatically if _LOD0, _LOD1... meshes are found.
|
||||
/// </summary>
|
||||
public static void CreatePrefabs(GameObject fbxRoot, AssetFolders folders, string assetName)
|
||||
{
|
||||
CreateSinglePrefab(fbxRoot, folders.Prefabs, assetName);
|
||||
}
|
||||
|
||||
public static GameObject CreatePrefab(GameObject fbxRoot, string prefabDir, string assetName)
|
||||
{
|
||||
return CreateSinglePrefab(fbxRoot, prefabDir, assetName);
|
||||
}
|
||||
|
||||
static GameObject CreateSinglePrefab(GameObject fbxRoot, string prefabDir, string assetName)
|
||||
{
|
||||
EnsureFolder(prefabDir);
|
||||
|
||||
var instance = Object.Instantiate(fbxRoot);
|
||||
instance.name = assetName;
|
||||
|
||||
if (TrySetupLODGroup(instance))
|
||||
Debug.Log($"[FBXImporter] LODGroup configured for '{assetName}'");
|
||||
|
||||
string prefabPath = $"{prefabDir}/{assetName}.prefab";
|
||||
var prefab = PrefabUtility.SaveAsPrefabAsset(instance, prefabPath);
|
||||
Object.DestroyImmediate(instance);
|
||||
|
||||
if (prefab != null) Debug.Log($"[FBXImporter] Prefab → {prefabPath}");
|
||||
else Debug.LogError($"[FBXImporter] Failed to save prefab at {prefabPath}");
|
||||
|
||||
return prefab;
|
||||
}
|
||||
|
||||
static bool TrySetupLODGroup(GameObject root)
|
||||
{
|
||||
var lodMap = new SortedDictionary<int, List<Renderer>>();
|
||||
foreach (var r in root.GetComponentsInChildren<Renderer>(true))
|
||||
{
|
||||
int idx = ExtractLODIndex(r.gameObject.name.ToUpper());
|
||||
if (idx < 0) continue;
|
||||
if (!lodMap.ContainsKey(idx)) lodMap[idx] = new List<Renderer>();
|
||||
lodMap[idx].Add(r);
|
||||
}
|
||||
|
||||
if (lodMap.Count < 2) return false;
|
||||
|
||||
var existing = root.GetComponent<LODGroup>();
|
||||
if (existing != null) Object.DestroyImmediate(existing);
|
||||
|
||||
var group = root.AddComponent<LODGroup>();
|
||||
float[] thresh = { 0.6f, 0.3f, 0.1f, 0.05f, 0.01f };
|
||||
var lods = new LOD[lodMap.Count];
|
||||
int i = 0;
|
||||
foreach (var kvp in lodMap)
|
||||
{
|
||||
lods[i] = new LOD(i < thresh.Length ? thresh[i] : 0.01f, kvp.Value.ToArray());
|
||||
i++;
|
||||
}
|
||||
group.SetLODs(lods);
|
||||
group.RecalculateBounds();
|
||||
return true;
|
||||
}
|
||||
|
||||
static int ExtractLODIndex(string name)
|
||||
{
|
||||
int idx = name.LastIndexOf("_LOD");
|
||||
if (idx < 0) return -1;
|
||||
return int.TryParse(name.Substring(idx + 4), out int level) ? level : -1;
|
||||
}
|
||||
|
||||
static void EnsureFolder(string path)
|
||||
{
|
||||
if (AssetDatabase.IsValidFolder(path)) return;
|
||||
string parent = Path.GetDirectoryName(path).Replace("\\", "/");
|
||||
string child = Path.GetFileName(path);
|
||||
if (!AssetDatabase.IsValidFolder(parent)) EnsureFolder(parent);
|
||||
AssetDatabase.CreateFolder(parent, child);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c3b17b0a218bf80458531ba1c789cfed
|
||||
@@ -0,0 +1,126 @@
|
||||
using UnityEditor;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Persistent registry of FBX files seen by the importer.
|
||||
/// Stores GUID + filename so the file is human-readable.
|
||||
/// Format per line: "guid TAB filename.fbx"
|
||||
/// Registry file is stored next to this script, wherever the plugin is installed.
|
||||
/// </summary>
|
||||
public static class ProcessedFBXRegistry
|
||||
{
|
||||
// Resolved dynamically so the registry follows the plugin wherever it's installed
|
||||
static string _registryPath;
|
||||
static string RegistryPath
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_registryPath != null) return _registryPath;
|
||||
|
||||
var guids = AssetDatabase.FindAssets("ProcessedFBXRegistry t:Script");
|
||||
foreach (var guid in guids)
|
||||
{
|
||||
string path = AssetDatabase.GUIDToAssetPath(guid);
|
||||
if (path.Contains("FBXImporter"))
|
||||
{
|
||||
_registryPath = Path.GetDirectoryName(path).Replace("\\", "/") + "/processed_fbx.txt";
|
||||
return _registryPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback — should never happen if plugin is properly installed
|
||||
_registryPath = "Assets/processed_fbx.txt";
|
||||
return _registryPath;
|
||||
}
|
||||
}
|
||||
|
||||
// guid → filename (for display)
|
||||
static Dictionary<string, string> _registry;
|
||||
|
||||
static Dictionary<string, string> Registry
|
||||
{
|
||||
get { if (_registry == null) Load(); return _registry; }
|
||||
}
|
||||
|
||||
public static bool IsRegistered(string assetPath)
|
||||
{
|
||||
string guid = AssetDatabase.AssetPathToGUID(assetPath);
|
||||
return !string.IsNullOrEmpty(guid) && Registry.ContainsKey(guid);
|
||||
}
|
||||
|
||||
public static void Register(System.Collections.Generic.IEnumerable<string> assetPaths)
|
||||
{
|
||||
bool changed = false;
|
||||
foreach (var path in assetPaths)
|
||||
{
|
||||
string guid = AssetDatabase.AssetPathToGUID(path);
|
||||
if (string.IsNullOrEmpty(guid)) continue;
|
||||
if (!Registry.ContainsKey(guid))
|
||||
{
|
||||
Registry[guid] = Path.GetFileName(path);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) Save();
|
||||
}
|
||||
|
||||
/// <summary>Removes a FBX from the registry by asset path.</summary>
|
||||
public static void Unregister(string assetPath)
|
||||
{
|
||||
string guid = AssetDatabase.AssetPathToGUID(assetPath);
|
||||
if (!string.IsNullOrEmpty(guid))
|
||||
UnregisterByGuid(guid, Path.GetFileName(assetPath));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a FBX from the registry directly by GUID.
|
||||
/// Used by FBXDeletionWatcher where the GUID is resolved BEFORE
|
||||
/// Unity deletes the file — avoids the race condition where
|
||||
/// AssetPathToGUID returns empty after deletion starts.
|
||||
/// </summary>
|
||||
public static void UnregisterByGuid(string guid, string displayName = "")
|
||||
{
|
||||
if (string.IsNullOrEmpty(guid)) return;
|
||||
if (Registry.Remove(guid))
|
||||
{
|
||||
Save();
|
||||
string name = string.IsNullOrEmpty(displayName) ? guid : displayName;
|
||||
UnityEngine.Debug.Log($"[FBXImporter] Removed from registry: {name}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Persistence ───────────────────────────────────────────────────────────
|
||||
|
||||
static void Load()
|
||||
{
|
||||
_registry = new Dictionary<string, string>();
|
||||
if (!File.Exists(RegistryPath)) return;
|
||||
|
||||
foreach (var line in File.ReadAllLines(RegistryPath))
|
||||
{
|
||||
string trimmed = line.Trim();
|
||||
if (string.IsNullOrEmpty(trimmed)) continue;
|
||||
|
||||
var parts = trimmed.Split(new[] { '\t', ' ' }, 2, System.StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length >= 1)
|
||||
_registry[parts[0]] = parts.Length >= 2 ? parts[1] : "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
static void Save()
|
||||
{
|
||||
string dir = Path.GetDirectoryName(RegistryPath);
|
||||
if (!Directory.Exists(dir)) Directory.CreateDirectory(dir);
|
||||
|
||||
var lines = Registry.Select(kvp => $"{kvp.Key}\t{kvp.Value}").ToArray();
|
||||
File.WriteAllLines(RegistryPath, lines);
|
||||
|
||||
// Nullify cache so the next access reloads from disk
|
||||
_registry = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 85c39d9a08dc3d04a974255db5d713ed
|
||||
@@ -0,0 +1,48 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
public enum RenderPipeline { BuiltIn, URP, HDRP }
|
||||
|
||||
public static class ShaderDetector
|
||||
{
|
||||
static RenderPipeline? _cached;
|
||||
|
||||
public static RenderPipeline GetPipeline()
|
||||
{
|
||||
if (_cached.HasValue) return _cached.Value;
|
||||
var rp = GraphicsSettings.currentRenderPipeline;
|
||||
if (rp == null) _cached = RenderPipeline.BuiltIn;
|
||||
else if (rp.GetType().Name.Contains("Universal")) _cached = RenderPipeline.URP;
|
||||
else if (rp.GetType().Name.Contains("HD")) _cached = RenderPipeline.HDRP;
|
||||
else _cached = RenderPipeline.BuiltIn;
|
||||
return _cached.Value;
|
||||
}
|
||||
|
||||
public static Shader GetLitShader()
|
||||
{
|
||||
switch (GetPipeline())
|
||||
{
|
||||
case RenderPipeline.URP: return Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard");
|
||||
case RenderPipeline.HDRP: return Shader.Find("HDRP/Lit") ?? Shader.Find("Standard");
|
||||
default: return Shader.Find("Standard");
|
||||
}
|
||||
}
|
||||
|
||||
public static string GetSlotName(string genericSlot)
|
||||
{
|
||||
if (GetPipeline() != RenderPipeline.HDRP) return genericSlot;
|
||||
switch (genericSlot)
|
||||
{
|
||||
case "_BaseMap": return "_BaseColorMap";
|
||||
case "_BumpMap": return "_NormalMap";
|
||||
case "_MetallicGlossMap": return "_MaskMap";
|
||||
case "_SpecGlossMap": return "_MaskMap";
|
||||
case "_OcclusionMap": return "_MaskMap";
|
||||
case "_EmissionMap": return "_EmissiveColorMap";
|
||||
default: return genericSlot;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dc40db3111674c04f94d8fdb63aed0ab
|
||||
@@ -0,0 +1,142 @@
|
||||
using System.IO;
|
||||
using System.Collections.Generic;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
public static class TextureMatcher
|
||||
{
|
||||
static readonly string[] SupportedExtensions = { ".png", ".tga", ".jpg", ".jpeg", ".tiff", ".exr" };
|
||||
|
||||
/// <summary>
|
||||
/// Maps shader slot -> keywords matched anywhere in filename (case-insensitive).
|
||||
/// More specific keywords listed first to avoid false positives.
|
||||
/// </summary>
|
||||
public static readonly Dictionary<string, string[]> SlotKeywords = new Dictionary<string, string[]>
|
||||
{
|
||||
{ "_BaseMap", new[] { "albedo", "diffuse", "basecolor", "base_color", "_diff", "_dif", "_D", "_d" } },
|
||||
{ "_BumpMap", new[] { "normal", "nrm", "_N", "_n" } },
|
||||
{ "_MetallicGlossMap", new[] { "metallic", "metal", "gloss", "_M", "_m" } },
|
||||
{ "_SpecGlossMap", new[] { "roughness", "rough", "spec", "_R", "_r" } },
|
||||
{ "_OcclusionMap", new[] { "occlusion", "ambientocclusion", "_AO", "_ao" } },
|
||||
{ "_EmissionMap", new[] { "emissive", "emission", "_E", "_e" } },
|
||||
{ "_ParallaxMap", new[] { "height", "parallax", "opacity", "alpha", "_H", "_h" } },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Finds a texture for the given material and shader slot.
|
||||
///
|
||||
/// PASS 1 - Prefix match (strict):
|
||||
/// filename starts with baseName AND contains a slot keyword
|
||||
/// e.g. baseName="Robot", slot="_BaseMap" -> matches "Robot_D.jpg", "Robot_diffuse.png"
|
||||
///
|
||||
/// PASS 2 - Fallback (keyword only, no prefix required):
|
||||
/// If pass 1 finds nothing, scan ALL textures in the folder for the keyword.
|
||||
/// Useful when textures use a different prefix than the material name.
|
||||
/// e.g. material="Robot", textures="Drone_diff.jpg" -> connected via "diff" keyword
|
||||
/// </summary>
|
||||
public static Texture2D FindForSlot(string textureDir, string baseName, string shaderSlot)
|
||||
{
|
||||
if (!SlotKeywords.TryGetValue(shaderSlot, out string[] keywords)) return null;
|
||||
if (!AssetDatabase.IsValidFolder(textureDir)) return null;
|
||||
|
||||
string[] guids = AssetDatabase.FindAssets("t:Texture2D", new[] { textureDir });
|
||||
|
||||
// Pass 1: prefix + keyword
|
||||
foreach (var guid in guids)
|
||||
{
|
||||
string assetPath = AssetDatabase.GUIDToAssetPath(guid);
|
||||
if (!IsSupportedExtension(assetPath)) continue;
|
||||
|
||||
string filename = Path.GetFileNameWithoutExtension(assetPath).ToLower();
|
||||
if (!filename.StartsWith(baseName.ToLower())) continue;
|
||||
|
||||
foreach (var keyword in keywords)
|
||||
{
|
||||
if (filename.Contains(keyword.ToLower()))
|
||||
{
|
||||
var tex = AssetDatabase.LoadAssetAtPath<Texture2D>(assetPath);
|
||||
if (tex == null) continue;
|
||||
if (shaderSlot == "_BumpMap") EnsureNormalMapType(assetPath);
|
||||
return tex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: keyword only fallback (mismatched prefix)
|
||||
foreach (var guid in guids)
|
||||
{
|
||||
string assetPath = AssetDatabase.GUIDToAssetPath(guid);
|
||||
if (!IsSupportedExtension(assetPath)) continue;
|
||||
|
||||
string filename = Path.GetFileNameWithoutExtension(assetPath).ToLower();
|
||||
|
||||
foreach (var keyword in keywords)
|
||||
{
|
||||
if (filename.Contains(keyword.ToLower()))
|
||||
{
|
||||
var tex = AssetDatabase.LoadAssetAtPath<Texture2D>(assetPath);
|
||||
if (tex == null) continue;
|
||||
if (shaderSlot == "_BumpMap") EnsureNormalMapType(assetPath);
|
||||
Debug.Log($"[FBXImporter] Fallback match: {shaderSlot} <- {Path.GetFileName(assetPath)} (mat: {baseName})");
|
||||
return tex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips DCC-specific suffixes to recover the clean base name.
|
||||
/// Maya : "hero_blinn1" -> "hero" / "standardSurface1" -> "Material"
|
||||
/// Both : "tree_MAT" -> "tree"
|
||||
/// </summary>
|
||||
public static string CleanMaterialName(string rawName, FBXSource source)
|
||||
{
|
||||
string name = rawName;
|
||||
|
||||
if (source == FBXSource.Maya)
|
||||
{
|
||||
string[] mayaSuffixes = { "_blinn", "_phong", "_lambert", "_aiStandardSurface", "_standardSurface", "_SG" };
|
||||
foreach (var s in mayaSuffixes)
|
||||
{
|
||||
int idx = name.ToLower().IndexOf(s.ToLower());
|
||||
if (idx > 0) { name = name.Substring(0, idx); break; }
|
||||
}
|
||||
|
||||
if (name.ToLower().StartsWith("standardsurface") ||
|
||||
name.ToLower().StartsWith("lambert") ||
|
||||
name.ToLower().StartsWith("phong") ||
|
||||
name.ToLower().StartsWith("blinn"))
|
||||
name = "Material";
|
||||
|
||||
name = System.Text.RegularExpressions.Regex.Replace(name, @"\d+$", "").TrimEnd('_');
|
||||
}
|
||||
|
||||
foreach (var s in new[] { "_MAT", "_Mat", "_material", "_Material" })
|
||||
if (name.EndsWith(s)) name = name.Substring(0, name.Length - s.Length);
|
||||
|
||||
return string.IsNullOrWhiteSpace(name) ? rawName : name;
|
||||
}
|
||||
|
||||
static bool IsSupportedExtension(string assetPath)
|
||||
{
|
||||
string ext = Path.GetExtension(assetPath).ToLower();
|
||||
foreach (var e in SupportedExtensions)
|
||||
if (ext == e) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
static void EnsureNormalMapType(string assetPath)
|
||||
{
|
||||
var ti = AssetImporter.GetAtPath(assetPath) as TextureImporter;
|
||||
if (ti != null && ti.textureType != TextureImporterType.NormalMap)
|
||||
{
|
||||
ti.textureType = TextureImporterType.NormalMap;
|
||||
ti.SaveAndReimport();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user