First commit for private source control. Older commits available on Github.

This commit is contained in:
2026-03-26 12:52:52 +00:00
parent a04c602626
commit 2d449c4a17
2176 changed files with 408185 additions and 0 deletions

Binary file not shown.

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 7bcf66bc161f4b768faf5d95e4d786f3
timeCreated: 1773830070

Binary file not shown.

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: b4956f4efd45455b907a6183ed3d06f1
timeCreated: 1773830079

View File

@@ -0,0 +1,18 @@
using BriarQueen.Data.Identifiers;
using UnityEngine;
namespace BriarQueen.Framework.Managers.Achievements.Data
{
[CreateAssetMenu(fileName = "New Achievement", menuName = "Briar Queen/Achievements/New Achievement")]
public class AchievementSo : ScriptableObject
{
[Header("Achievement Details")]
public AchievementID Achievement;
public string Name;
public string Description;
[Header("Steam Integration")]
public string SteamUniqueIdentifier;
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: ef4d39885de444d9840717db9173c5b5
timeCreated: 1772721525

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 9f220a47c48b444abc68a41e0aa5cf9f
timeCreated: 1772728857

View File

@@ -0,0 +1,69 @@
using System;
using System.Linq;
using BriarQueen.Framework.Coordinators.Events;
using BriarQueen.Framework.Events.Progression;
using BriarQueen.Framework.Registries;
using Steamworks;
using UnityEngine;
using VContainer;
namespace BriarQueen.Framework.Managers.Achievements.Steam
{
public class SteamManager : IDisposable, IManager
{
private const uint STEAM_APP_ID = 0;
private readonly AchievementRegistry _achievementRegistry;
private readonly EventCoordinator _eventCoordinator;
public bool Initialized { get; private set; }
[Inject]
public SteamManager(EventCoordinator eventCoordinator, AchievementRegistry achievementRegistry)
{
_eventCoordinator = eventCoordinator;
_achievementRegistry = achievementRegistry;
}
public void Dispose()
{
_eventCoordinator.Unsubscribe<UnlockAchievementEvent>(OnUnlockAchievement);
}
public void Initialize()
{
try
{
SteamClient.Init(STEAM_APP_ID);
Initialized = true;
}
catch (Exception e)
{
Debug.Log($"[Steam Manager] Steam Failed to Init {e.Message}");
return;
}
_eventCoordinator.Subscribe<UnlockAchievementEvent>(OnUnlockAchievement);
}
private void OnUnlockAchievement(UnlockAchievementEvent evt)
{
if (!_achievementRegistry.TryGetAchievement(evt.AchievementID, out var achievement))
return;
var steamAch =
SteamUserStats.Achievements.FirstOrDefault(x => x.Identifier == achievement.SteamUniqueIdentifier);
if (string.IsNullOrEmpty(steamAch.Identifier))
{
Debug.LogWarning($"Steam achievement '{evt.AchievementID}' not found in Steamworks.");
return;
}
if (steamAch.State) return;
steamAch.Trigger();
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 91a1d462420f49cb8cb6428d41848983
timeCreated: 1772728857

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 90e810a6ab61441b947dc8c2add38f83
timeCreated: 1769802028

Binary file not shown.

View File

@@ -0,0 +1,779 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using BriarQueen.Data.Identifiers;
using BriarQueen.Framework.Coordinators.Events;
using BriarQueen.Framework.Events.Audio;
using BriarQueen.Framework.Events.UI;
using BriarQueen.Framework.Managers.Audio.Data;
using BriarQueen.Framework.Registries;
using Cysharp.Threading.Tasks;
using PrimeTween;
using UnityEngine;
using UnityEngine.Audio;
using VContainer;
using Object = UnityEngine.Object;
namespace BriarQueen.Framework.Managers.Audio
{
/// <summary>
/// AudioManager (modifier-based mixer control)
/// Key idea:
/// - Settings set "base" volumes (in dB) per mixer parameter.
/// - Runtime states (Pause duck, Voice duck) apply "modifiers" (extra dB offsets).
/// - Effective mixer value is always: effectiveDb = baseDb + modifiersDb
/// </summary>
public class AudioManager : IDisposable, IManager
{
private const int INITIAL_AMBIENCE_SOURCES = 3;
private const int INITIAL_SFX_SOURCES = 6;
private const float PAUSE_DUCK_TARGET_DB = -18f;
private const float PAUSE_DUCK_FADE_SECONDS = 0.25f;
private const float DEFAULT_VOICE_DUCK_TARGET_DB = -20f;
private readonly AudioMixer _audioMixer;
private readonly AudioRegistry _audioRegistry;
private readonly EventCoordinator _eventCoordinator;
private readonly Dictionary<string, float> _baseDb = new();
private readonly List<GameObject> _createdAudioObjects = new();
private readonly List<AudioSource> _ambienceSources = new();
private readonly List<AudioFileSo> _currentAmbienceTracks = new();
private readonly List<SfxChannel> _sfxChannels = new();
private AudioSource _musicSourceA;
private AudioSource _musicSourceB;
private AudioSource _voiceSource;
private AudioSource _uiSource;
private string _activeVoiceSubtitleId;
private AudioFileSo _currentMusicTrack;
private CancellationTokenSource _musicDuckCts;
private CancellationTokenSource _musicFadeCts;
private CancellationTokenSource _voiceCts;
private float _musicDuckDbCurrent;
private float _pauseDuckDbCurrent;
private Sequence _musicDuckSequence;
private Sequence _pauseDuckSequence;
private bool _voiceFinishedPublished;
private bool _disposed;
public bool Initialized { get; private set; }
[Inject]
public AudioManager(AudioMixer mainMixer, AudioRegistry audioRegistry, EventCoordinator eventCoordinator)
{
_audioMixer = mainMixer;
_audioRegistry = audioRegistry;
_eventCoordinator = eventCoordinator;
}
public void Initialize()
{
if (_disposed)
{
Debug.LogWarning($"[{nameof(AudioManager)}] Initialize called after Dispose.");
return;
}
if (Initialized)
{
Debug.LogWarning($"[{nameof(AudioManager)}] Initialize called more than once.");
return;
}
Debug.Log($"[{nameof(AudioManager)}] Initializing...");
CreateSources();
PrimeMixerBaseValues();
_pauseDuckDbCurrent = 0f;
_musicDuckDbCurrent = 0f;
ApplyAllEffectiveVolumes();
_eventCoordinator.Subscribe<UIStackChangedEvent>(OnUIStackChanged);
Initialized = true;
Debug.Log($"[{nameof(AudioManager)}] Initialized.");
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
if (Initialized)
_eventCoordinator.Unsubscribe<UIStackChangedEvent>(OnUIStackChanged);
StopAndDispose(ref _musicDuckCts);
StopAndDispose(ref _musicFadeCts);
StopAndDispose(ref _voiceCts);
if (_pauseDuckSequence.isAlive)
{
_pauseDuckSequence.Stop();
_pauseDuckSequence = default;
}
if (_musicDuckSequence.isAlive)
{
_musicDuckSequence.Stop();
_musicDuckSequence = default;
}
foreach (var go in _createdAudioObjects)
{
if (go != null)
Object.Destroy(go);
}
_createdAudioObjects.Clear();
_ambienceSources.Clear();
_sfxChannels.Clear();
_currentAmbienceTracks.Clear();
_baseDb.Clear();
_musicSourceA = null;
_musicSourceB = null;
_voiceSource = null;
_uiSource = null;
_currentMusicTrack = null;
_activeVoiceSubtitleId = null;
_voiceFinishedPublished = false;
Initialized = false;
}
private void CreateSources()
{
_musicSourceA = CreateAudioSource("Music_Source_A", AudioMixerGroups.MUSIC_GROUP);
_musicSourceB = CreateAudioSource("Music_Source_B", AudioMixerGroups.MUSIC_GROUP);
_voiceSource = CreateAudioSource("Voice_Source", AudioMixerGroups.VOICE_GROUP);
_uiSource = CreateAudioSource("UI_Source", AudioMixerGroups.UI_GROUP);
for (var i = 0; i < INITIAL_SFX_SOURCES; i++)
{
var src = CreateAudioSource($"SFX_Source_{i}", AudioMixerGroups.SFX_GROUP);
_sfxChannels.Add(new SfxChannel
{
Source = src,
StartedAtUnscaled = -999f
});
}
for (var i = 0; i < INITIAL_AMBIENCE_SOURCES; i++)
{
_ambienceSources.Add(CreateAudioSource($"Ambience_Source_{i}", AudioMixerGroups.AMBIENCE_GROUP));
}
}
private void PrimeMixerBaseValues()
{
PrimeBaseFromMixer(AudioMixerParameters.MASTER_VOLUME);
PrimeBaseFromMixer(AudioMixerParameters.MUSIC_VOLUME);
PrimeBaseFromMixer(AudioMixerParameters.SFX_VOLUME);
PrimeBaseFromMixer(AudioMixerParameters.AMBIENCE_VOLUME);
PrimeBaseFromMixer(AudioMixerParameters.VOICE_VOLUME);
PrimeBaseFromMixer(AudioMixerParameters.UI_VOLUME);
}
public void SetVolume(string parameter, float value01)
{
if (!Initialized)
{
Debug.LogWarning($"[{nameof(AudioManager)}] SetVolume called before Initialize.");
return;
}
var linear = Mathf.Clamp01(value01);
var db = Linear01ToDb(linear);
_baseDb[parameter] = db;
ApplyEffectiveVolume(parameter);
}
private static float Linear01ToDb(float linear01)
{
var lin = Mathf.Max(linear01, 0.0001f);
return Mathf.Log10(lin) * 20f;
}
private void PrimeBaseFromMixer(string parameter)
{
if (_audioMixer != null && _audioMixer.GetFloat(parameter, out var db))
_baseDb[parameter] = db;
else
_baseDb[parameter] = 0f;
}
private void ApplyAllEffectiveVolumes()
{
ApplyEffectiveVolume(AudioMixerParameters.MASTER_VOLUME);
ApplyEffectiveVolume(AudioMixerParameters.MUSIC_VOLUME);
ApplyEffectiveVolume(AudioMixerParameters.SFX_VOLUME);
ApplyEffectiveVolume(AudioMixerParameters.AMBIENCE_VOLUME);
ApplyEffectiveVolume(AudioMixerParameters.VOICE_VOLUME);
ApplyEffectiveVolume(AudioMixerParameters.UI_VOLUME);
}
private void ApplyEffectiveVolume(string parameter)
{
if (_audioMixer == null || string.IsNullOrWhiteSpace(parameter))
return;
if (!_baseDb.TryGetValue(parameter, out var baseDb))
baseDb = 0f;
var effective = baseDb;
if (parameter == AudioMixerParameters.MUSIC_VOLUME ||
parameter == AudioMixerParameters.SFX_VOLUME ||
parameter == AudioMixerParameters.AMBIENCE_VOLUME)
{
effective += _pauseDuckDbCurrent;
}
if (parameter == AudioMixerParameters.MUSIC_VOLUME)
effective += _musicDuckDbCurrent;
_audioMixer.SetFloat(parameter, effective);
}
private void OnUIStackChanged(UIStackChangedEvent e)
{
if (!Initialized)
return;
if (e.AnyUIOpen)
OnGamePausedInternal().Forget();
else
OnGameUnpausedInternal().Forget();
}
private async UniTask OnGamePausedInternal()
{
PauseVoiceSource(true);
await TweenPauseDuckTo(PAUSE_DUCK_TARGET_DB, PAUSE_DUCK_FADE_SECONDS);
}
private async UniTask OnGameUnpausedInternal()
{
await TweenPauseDuckTo(0f, PAUSE_DUCK_FADE_SECONDS);
PauseVoiceSource(false);
}
private async UniTask TweenPauseDuckTo(float targetDb, float seconds)
{
if (_pauseDuckSequence.isAlive)
{
_pauseDuckSequence.Stop();
_pauseDuckSequence = default;
}
seconds = Mathf.Max(0f, seconds);
var from = _pauseDuckDbCurrent;
_pauseDuckSequence = Sequence.Create(useUnscaledTime: true)
.Group(Tween.Custom(
from,
targetDb,
seconds,
v =>
{
_pauseDuckDbCurrent = v;
ApplyEffectiveVolume(AudioMixerParameters.MUSIC_VOLUME);
ApplyEffectiveVolume(AudioMixerParameters.SFX_VOLUME);
ApplyEffectiveVolume(AudioMixerParameters.AMBIENCE_VOLUME);
},
Ease.OutCubic,
useUnscaledTime: true));
await _pauseDuckSequence.ToUniTask();
_pauseDuckSequence = default;
}
public void PauseVoiceSource(bool paused)
{
if (!Initialized || _voiceSource == null)
return;
if (paused)
_voiceSource.Pause();
else
_voiceSource.UnPause();
}
public void Play(string audioName)
{
if (!Initialized)
{
Debug.LogWarning($"[{nameof(AudioManager)}] Play called before Initialize.");
return;
}
if (!_audioRegistry.TryGetAudio(audioName, out var audioFile))
{
Debug.LogWarning($"[AudioManager] Audio '{audioName}' not found in registry!");
return;
}
if (audioFile is AudioFileSo audio)
Play(audio);
else
Debug.LogWarning($"[AudioManager] Audio '{audioName}' is not a valid AudioSO!");
}
public void Play(AudioFileSo audioData)
{
if (!Initialized || !audioData || !audioData.Clip)
return;
switch (audioData.Type)
{
case TrackType.Music:
CrossfadeMusic(audioData, audioData.FadeTime > 0f ? audioData.FadeTime : 1.0f).Forget();
break;
case TrackType.Ambience:
if (!_currentAmbienceTracks.Contains(audioData))
{
_currentAmbienceTracks.Add(audioData);
PlayOnAvailableAmbienceSource(audioData);
}
break;
case TrackType.Sfx:
PlaySfx(audioData);
break;
case TrackType.UIFX:
PlayOneShotAsync(_uiSource, audioData).Forget();
break;
case TrackType.Voice:
PlayVoiceLine(audioData).Forget();
break;
}
if (audioData.DuckMusic)
DuckMusicAsync(audioData.Clip.length, audioData.FadeTime).Forget();
}
private async UniTaskVoid PlayVoiceLine(AudioFileSo audioData)
{
if (!Initialized || _voiceSource == null || audioData?.Clip == null)
return;
StopAndDispose(ref _voiceCts);
_voiceCts = new CancellationTokenSource();
var token = _voiceCts.Token;
_activeVoiceSubtitleId = SubtitleIdentifiers.Get(audioData.MatchingSubtitleID);
_voiceFinishedPublished = false;
_eventCoordinator.Publish(new VoiceLineStartedEvent(_activeVoiceSubtitleId));
_voiceSource.clip = audioData.Clip;
_voiceSource.pitch = audioData.Pitch;
_voiceSource.volume = audioData.Volume;
_voiceSource.loop = false;
_voiceSource.priority = audioData.Priority;
_voiceSource.Play();
try
{
await UniTask.WaitUntil(() => !_voiceSource.isPlaying, cancellationToken: token);
}
catch (OperationCanceledException)
{
}
finally
{
PublishVoiceFinishedIfNeeded();
}
}
private void PublishVoiceFinishedIfNeeded()
{
if (_voiceFinishedPublished)
return;
if (!string.IsNullOrEmpty(_activeVoiceSubtitleId))
_eventCoordinator.Publish(new VoiceLineFinishedEvent(_activeVoiceSubtitleId));
_voiceFinishedPublished = true;
_activeVoiceSubtitleId = null;
}
private async UniTask DuckMusicAsync(float clipLengthSeconds, float fadeTimeSeconds)
{
if (!Initialized)
return;
StopAndDispose(ref _musicDuckCts);
_musicDuckCts = new CancellationTokenSource();
var token = _musicDuckCts.Token;
fadeTimeSeconds = Mathf.Max(0.0001f, fadeTimeSeconds);
var duckTarget = DEFAULT_VOICE_DUCK_TARGET_DB;
try
{
await TweenMusicDuckTo(duckTarget, fadeTimeSeconds, token);
var hold = clipLengthSeconds - fadeTimeSeconds * 2f;
if (hold > 0.01f)
await UniTask.Delay(TimeSpan.FromSeconds(hold), cancellationToken: token);
await TweenMusicDuckTo(0f, fadeTimeSeconds, token);
}
catch (OperationCanceledException)
{
await TweenMusicDuckTo(0f, 0.35f, CancellationToken.None);
}
}
private async UniTask TweenMusicDuckTo(float targetDb, float seconds, CancellationToken token)
{
if (_musicDuckSequence.isAlive)
{
_musicDuckSequence.Stop();
_musicDuckSequence = default;
}
seconds = Mathf.Max(0f, seconds);
var from = _musicDuckDbCurrent;
_musicDuckSequence = Sequence.Create(useUnscaledTime: true)
.Group(Tween.Custom(
from,
targetDb,
seconds,
v =>
{
_musicDuckDbCurrent = v;
ApplyEffectiveVolume(AudioMixerParameters.MUSIC_VOLUME);
},
Ease.OutCubic,
useUnscaledTime: true));
await _musicDuckSequence.ToUniTask(cancellationToken: token);
_musicDuckSequence = default;
}
public async UniTask CrossfadeMusic(AudioFileSo newTrack, float duration)
{
if (!Initialized || !newTrack || !newTrack.Clip)
return;
if (_currentMusicTrack == newTrack)
return;
StopAndDispose(ref _musicFadeCts);
_musicFadeCts = new CancellationTokenSource();
var token = _musicFadeCts.Token;
var activeSource = _musicSourceA.isPlaying
? _musicSourceA
: _musicSourceB.isPlaying
? _musicSourceB
: null;
var inactiveSource = activeSource == _musicSourceA ? _musicSourceB : _musicSourceA;
PlayOnSource(inactiveSource, newTrack);
if (activeSource == null)
{
inactiveSource.volume = newTrack.Volume;
_currentMusicTrack = newTrack;
_eventCoordinator.Publish(new MusicTrackChangedEvent(newTrack));
return;
}
duration = Mathf.Max(0.0001f, duration);
var elapsed = 0f;
var startVolume = activeSource.volume;
try
{
while (elapsed < duration)
{
token.ThrowIfCancellationRequested();
var t = elapsed / duration;
activeSource.volume = Mathf.Lerp(startVolume, 0f, t);
inactiveSource.volume = Mathf.Lerp(0f, newTrack.Volume, t);
elapsed += Time.unscaledDeltaTime;
await UniTask.Yield(PlayerLoopTiming.Update, token);
}
activeSource.volume = 0f;
inactiveSource.volume = newTrack.Volume;
}
catch (OperationCanceledException)
{
return;
}
activeSource.Stop();
activeSource.volume = startVolume;
_currentMusicTrack = newTrack;
_eventCoordinator.Publish(new MusicTrackChangedEvent(newTrack));
}
private void PlaySfx(AudioFileSo audioData)
{
if (!Initialized || audioData == null || audioData.Clip == null)
return;
var channelIndex = GetBestSfxChannelIndex(audioData.Priority);
if (channelIndex < 0 || channelIndex >= _sfxChannels.Count)
return;
var src = _sfxChannels[channelIndex].Source;
if (src == null)
return;
if (src.isPlaying)
src.Stop();
src.priority = audioData.Priority;
src.pitch = audioData.Pitch;
src.PlayOneShot(audioData.Clip, audioData.Volume);
_sfxChannels[channelIndex] = new SfxChannel
{
Source = src,
StartedAtUnscaled = Time.unscaledTime
};
}
private int GetBestSfxChannelIndex(int incomingPriority)
{
for (var i = 0; i < _sfxChannels.Count; i++)
{
var src = _sfxChannels[i].Source;
if (src == null)
continue;
if (!src.isPlaying)
return i;
}
var bestIndex = -1;
var worstPriority = int.MinValue;
var oldestStart = float.MaxValue;
for (var i = 0; i < _sfxChannels.Count; i++)
{
var src = _sfxChannels[i].Source;
if (src == null)
continue;
var p = src.priority;
var started = _sfxChannels[i].StartedAtUnscaled;
if (p > worstPriority || (p == worstPriority && started < oldestStart))
{
worstPriority = p;
oldestStart = started;
bestIndex = i;
}
}
return bestIndex;
}
public void StopAmbience(AudioFileSo audioData)
{
if (!Initialized || !audioData || !audioData.Clip || audioData.Type != TrackType.Ambience)
return;
if (_currentAmbienceTracks.Remove(audioData))
{
foreach (var source in _ambienceSources.Where(s => s != null && s.clip == audioData.Clip))
source.Stop();
}
}
public void StopAllAmbience()
{
if (!Initialized)
return;
foreach (var s in _ambienceSources)
{
if (s != null)
s.Stop();
}
_currentAmbienceTracks.Clear();
}
private void PlayOnAvailableAmbienceSource(AudioFileSo audioData)
{
var source = _ambienceSources.FirstOrDefault(s => s != null && !s.isPlaying);
if (source == null)
{
source = CreateAudioSource(
$"Ambience_Source_{_ambienceSources.Count}",
AudioMixerGroups.AMBIENCE_GROUP);
_ambienceSources.Add(source);
}
PlayOnSource(source, audioData);
}
public void StopMusic()
{
if (!Initialized)
return;
StopAndDispose(ref _musicFadeCts);
if (_musicSourceA != null)
{
_musicSourceA.Stop();
_musicSourceA.clip = null;
_musicSourceA.volume = 0f;
}
if (_musicSourceB != null)
{
_musicSourceB.Stop();
_musicSourceB.clip = null;
_musicSourceB.volume = 0f;
}
_currentMusicTrack = null;
}
public void StopVoice()
{
if (!Initialized)
return;
StopAndDispose(ref _voiceCts);
if (_voiceSource != null && _voiceSource.isPlaying)
_voiceSource.Stop();
PublishVoiceFinishedIfNeeded();
}
public void StopAllSfx()
{
if (!Initialized)
return;
for (var i = 0; i < _sfxChannels.Count; i++)
{
var src = _sfxChannels[i].Source;
if (src == null)
continue;
src.Stop();
_sfxChannels[i] = new SfxChannel
{
Source = src,
StartedAtUnscaled = -999f
};
}
}
public void StopAllAudio()
{
if (!Initialized)
return;
StopMusic();
StopVoice();
StopAllSfx();
StopAllAmbience();
if (_uiSource != null)
_uiSource.Stop();
}
private static void StopAndDispose(ref CancellationTokenSource cts)
{
if (cts == null)
return;
try
{
cts.Cancel();
}
catch
{
}
cts.Dispose();
cts = null;
}
private AudioSource CreateAudioSource(string name, string groupName)
{
var obj = new GameObject(name);
Object.DontDestroyOnLoad(obj);
_createdAudioObjects.Add(obj);
var src = obj.AddComponent<AudioSource>();
var group = _audioMixer.FindMatchingGroups(groupName);
if (group != null && group.Length > 0)
src.outputAudioMixerGroup = group[0];
else
Debug.LogWarning($"[AudioManager] Mixer Group '{groupName}' not found for {name}");
return src;
}
private async UniTaskVoid PlayOneShotAsync(AudioSource source, AudioFileSo audioData)
{
if (!Initialized || source == null || audioData == null || audioData.Clip == null)
return;
source.priority = audioData.Priority;
source.pitch = audioData.Pitch;
source.PlayOneShot(audioData.Clip, audioData.Volume);
var seconds = audioData.Clip.length / Mathf.Max(audioData.Pitch, 0.0001f);
await UniTask.Delay(TimeSpan.FromSeconds(seconds));
}
private void PlayOnSource(AudioSource source, AudioFileSo audioData)
{
if (!Initialized || source == null || audioData == null)
return;
source.clip = audioData.Clip;
source.loop = audioData.Loopable;
source.volume = audioData.Volume;
source.pitch = audioData.Pitch;
source.priority = audioData.Priority;
source.Play();
}
private struct SfxChannel
{
public AudioSource Source;
public float StartedAtUnscaled;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 2a9a5c68541441fead61270b45705435
timeCreated: 1769802028

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 2419f0c722d945eb8899b181c7f37012
timeCreated: 1773830009

View File

@@ -0,0 +1,129 @@
using BriarQueen.Data.Identifiers;
using NaughtyAttributes;
using UnityEngine;
namespace BriarQueen.Framework.Managers.Audio.Data
{
[CreateAssetMenu(menuName = "Briar Queen/Audio/New Audio File", fileName = "New Audio File")]
public class AudioFileSo : ScriptableObject
{
[Header("Audio Type")]
[SerializeField]
private TrackType _type;
[Header("Audio ID")]
[SerializeField]
[ShowIf(nameof(IsMusic))]
private MusicKey _musicKey;
[SerializeField]
[ShowIf(nameof(IsSfx))]
private SFXKey _sfxKey;
[SerializeField]
[ShowIf(nameof(IsUiFx))]
private UIFXKey _uiFxKey;
[SerializeField]
[ShowIf(nameof(IsAmbience))]
private AmbienceKey _ambienceKey;
[SerializeField]
[ShowIf(nameof(IsVoice))]
private VoiceKey _voiceKey;
[SerializeField]
[Tooltip("A globally unique identifier used for linking with subtitles or dialogue events.")]
private SubtitleKey _matchingSubtitleID;
[SerializeField]
[Tooltip("A readable display name (optional, for debugging or editor UI).")]
private string _displayName;
[Header("Audio Settings")]
[SerializeField]
private AudioClip _clip;
[SerializeField]
[Range(0f, 1f)]
private float _volume = 1f;
[SerializeField]
[Range(0.5f, 2f)]
private float _pitch = 1f;
[SerializeField]
[Range(0, 256)]
private int _priority = 128;
[SerializeField]
private bool _loopable;
[Header("Mixing & Behavior")]
[SerializeField]
[Tooltip("If true, music volume will duck during playback.")]
private bool _duckMusic;
[SerializeField]
[Tooltip("Seconds to fade music back in after ducking.")]
[Min(0.1f)]
private float _fadeTime = 0.5f;
[SerializeField]
[Tooltip("Optional narrative event name (for triggers, debugging, or cutscenes).")]
private string _narrativeEvent;
public TrackType Type => _type;
public bool IsMusic => _type == TrackType.Music;
public bool IsSfx => _type == TrackType.Sfx;
public bool IsUiFx => _type == TrackType.UIFX;
public bool IsAmbience => _type == TrackType.Ambience;
public bool IsVoice => _type == TrackType.Voice;
public MusicKey MusicKey => _musicKey;
public SFXKey SfxKey => _sfxKey;
public UIFXKey UiFxKey => _uiFxKey;
public AmbienceKey AmbienceKey => _ambienceKey;
public VoiceKey VoiceKey => _voiceKey;
public SubtitleKey MatchingSubtitleID => _matchingSubtitleID;
public string DisplayName => _displayName;
public AudioClip Clip => _clip;
public float Volume => _volume;
public float Pitch => _pitch;
public int Priority => _priority;
public bool Loopable => _loopable;
public bool DuckMusic => _duckMusic;
public float FadeTime => _fadeTime;
public string NarrativeEvent => _narrativeEvent;
public string UniqueID
{
get
{
return _type switch
{
TrackType.Music when _musicKey != MusicKey.None =>
AudioNameIdentifiers.Get(_musicKey),
TrackType.Sfx when _sfxKey != SFXKey.None =>
AudioNameIdentifiers.Get(_sfxKey),
TrackType.UIFX when _uiFxKey != UIFXKey.None =>
AudioNameIdentifiers.Get(_uiFxKey),
TrackType.Ambience when _ambienceKey != AmbienceKey.None =>
AudioNameIdentifiers.Get(_ambienceKey),
TrackType.Voice when _voiceKey != VoiceKey.None =>
AudioNameIdentifiers.Get(_voiceKey),
_ => string.Empty
};
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: e10f120272164594b27c0055a5d34c1b
timeCreated: 1769802228

View File

@@ -0,0 +1,34 @@
using System;
using BriarQueen.Data.IO.Saves;
using BriarQueen.Framework.Managers.IO;
using NaughtyAttributes;
using UnityEngine;
using VContainer;
namespace BriarQueen.Framework.Managers
{
public class DebugManager : MonoBehaviour
{
private SaveManager _saveManager;
[Header("Current Loaded Save")]
[SerializeField, ReadOnly]
private SaveGame _currentSave;
[Inject]
public void Construct(SaveManager saveManager)
{
_saveManager = saveManager;
}
public void Start()
{
_saveManager.OnSaveGameLoaded += AttachSaveGame;
}
private void AttachSaveGame(SaveGame save)
{
_currentSave = save;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 82bf9e8d9ecf4c329adc84acf1d572e1
timeCreated: 1773948069

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: db9965a5157f46b4aaa3abe51d9d3ddc
timeCreated: 1769720397

Binary file not shown.

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 005cc9eaed8f467aa2902f25d7f3f11c
timeCreated: 1773830283

View File

@@ -0,0 +1,9 @@
using Cysharp.Threading.Tasks;
namespace BriarQueen.Framework.Managers.Hints.Data
{
public abstract class BaseHint
{
public abstract UniTask Activate();
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 3f85579f4af9471b8cbe5ddcff8f5702
timeCreated: 1769724499

View File

@@ -0,0 +1,161 @@
// ==============================
// HintManager.cs (fallback to nearest lower stage)
// ==============================
using System;
using System.Collections.Generic;
using System.Threading;
using BriarQueen.Framework.Coordinators.Events;
using BriarQueen.Framework.Events.Gameplay;
using BriarQueen.Framework.Events.Progression;
using BriarQueen.Framework.Managers.Hints.Data;
using BriarQueen.Framework.Managers.Levels.Data;
using Cysharp.Threading.Tasks;
using UnityEngine;
using VContainer;
namespace BriarQueen.Framework.Managers.Hints
{
public class HintManager : IDisposable, IManager
{
private readonly EventCoordinator _eventCoordinator;
private readonly Dictionary<int, BaseHint> _hints = new();
private CancellationTokenSource _activeHintCts;
private BaseLevel _currentLevel;
private CancellationTokenSource _levelCts;
public bool Initialized { get; private set; }
[Inject]
public HintManager(EventCoordinator eventCoordinator)
{
_eventCoordinator = eventCoordinator;
}
public IReadOnlyDictionary<int, BaseHint> Hints => _hints;
public void Dispose()
{
_eventCoordinator.Unsubscribe<LevelChangedEvent>(OnLevelChanged);
_eventCoordinator.Unsubscribe<RequestHintEvent>(OnHintRequested);
CancelAndDispose(ref _activeHintCts);
CancelAndDispose(ref _levelCts);
_currentLevel = null;
_hints.Clear();
}
public void Initialize()
{
_eventCoordinator.Subscribe<LevelChangedEvent>(OnLevelChanged);
_eventCoordinator.Subscribe<RequestHintEvent>(OnHintRequested);
Initialized = true;
}
private void OnLevelChanged(LevelChangedEvent evt)
{
CancelAndDispose(ref _activeHintCts);
CancelAndDispose(ref _levelCts);
_levelCts = new CancellationTokenSource();
_hints.Clear();
_currentLevel = evt?.Level;
if (_currentLevel?.Hints == null) return;
foreach (var kvp in _currentLevel.Hints) _hints[kvp.Key] = kvp.Value;
}
private void OnHintRequested(RequestHintEvent evt)
{
RequestHint().Forget();
}
/// <summary>
/// Plays the hint for the current hint stage.
/// If no hint exists at that stage, it falls back to the nearest lower stage that has a hint.
/// </summary>
public async UniTask RequestHint()
{
if (_currentLevel == null || _hints.Count == 0) return;
var stage = Mathf.Max(0, _currentLevel.CurrentLevelHintStage);
if (!TryGetHintForStageOrFallback(stage, out var resolvedStage, out var hint) || hint == null)
// Optional: later show a “No hints available” toast
return;
// Cancel any in-progress hint playback (spam-proof).
CancelAndDispose(ref _activeHintCts);
_activeHintCts = new CancellationTokenSource();
var levelToken = _levelCts?.Token ?? CancellationToken.None;
using var linked = CancellationTokenSource.CreateLinkedTokenSource(levelToken, _activeHintCts.Token);
try
{
await hint.Activate().AttachExternalCancellation(linked.Token);
}
catch (OperationCanceledException)
{
// Fine: new request or level changed.
}
catch (Exception ex)
{
Debug.LogWarning(
$"[HintManager] Hint activation threw at requested stage {stage} (resolved {resolvedStage}): {ex}");
}
}
private bool TryGetHintForStageOrFallback(int requestedStage, out int resolvedStage, out BaseHint hint)
{
// Exact stage
if (_hints.TryGetValue(requestedStage, out hint) && hint != null)
{
resolvedStage = requestedStage;
return true;
}
// ✅ Fallback: nearest lower stage that exists
// (Designers can skip stages; hint still works.)
var best = -1;
// If you prefer performance, you can cache/sort keys once per level.
foreach (var k in _hints.Keys)
if (k <= requestedStage && k > best)
best = k;
if (best >= 0 && _hints.TryGetValue(best, out hint) && hint != null)
{
resolvedStage = best;
return true;
}
resolvedStage = -1;
hint = null;
return false;
}
private static void CancelAndDispose(ref CancellationTokenSource cts)
{
if (cts == null) return;
try
{
cts.Cancel();
}
catch
{
/* ignore */
}
cts.Dispose();
cts = null;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: b7556af28e404ff29c4c88500afceadb
timeCreated: 1769720397

View File

@@ -0,0 +1,8 @@
namespace BriarQueen.Framework.Managers
{
public interface IManager
{
bool Initialized { get; }
void Initialize();
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 8516f899cfe140c78c9eacfee4cb63b2
timeCreated: 1773842107

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: aaea558f2146422cbbe33cf3836c9ef0
timeCreated: 1769701587

Binary file not shown.

View File

@@ -0,0 +1,422 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using BriarQueen.Data.Identifiers;
using BriarQueen.Data.IO;
using BriarQueen.Data.IO.Saves;
using BriarQueen.Framework.Coordinators.Events;
using BriarQueen.Framework.Events.Save;
using BriarQueen.Framework.Extensions;
using Cysharp.Threading.Tasks;
using MemoryPack;
using UnityEngine;
using VContainer;
using SaveGame = BriarQueen.Data.IO.Saves.SaveGame;
namespace BriarQueen.Framework.Managers.IO
{
public class SaveManager : IDisposable, IManager
{
private const int MAX_RETRY_COUNT = 3;
private const int RETRY_DELAY_MS = 100;
private readonly EventCoordinator _eventCoordinator;
private readonly object _saveLock = new();
private CancellationTokenSource _currentSaveCts;
private DateTime _lastSaveTime;
[Inject]
public SaveManager(EventCoordinator eventCoordinator)
{
_eventCoordinator = eventCoordinator;
}
public bool IsGameLoaded { get; private set; }
public SaveGame CurrentSave { get; set; }
public void Dispose()
{
_eventCoordinator.Unsubscribe<RequestGameSaveEvent>(OnRequestedSave);
_currentSaveCts?.Cancel();
}
public event Action<SaveGame> OnSaveGameLoaded;
public event Action OnSaveGameSaved;
// Existing synchronous hook
public event Action<SaveGame> OnSaveRequested;
// New async hook that runs BEFORE cloning/serialization
public event Func<UniTask> OnBeforeSaveRequestedAsync;
public bool Initialized { get; private set; }
public void Initialize()
{
_eventCoordinator.Subscribe<RequestGameSaveEvent>(OnRequestedSave);
Initialized = true;
}
private void OnRequestedSave(RequestGameSaveEvent evt)
{
SaveGameDataLatest().Forget();
}
/// <summary>
/// Queues the latest save and cancels any in-progress save.
/// </summary>
public async UniTask SaveGameDataLatest()
{
CancellationTokenSource oldCts = null;
CancellationTokenSource myCts;
lock (_saveLock)
{
if (_currentSaveCts != null)
{
oldCts = _currentSaveCts;
_currentSaveCts.Cancel();
}
myCts = new CancellationTokenSource();
_currentSaveCts = myCts;
}
oldCts?.Dispose();
try
{
await SaveGameDataInternal(myCts.Token);
}
catch (OperationCanceledException)
{
// A newer save was requested; ignore.
}
finally
{
lock (_saveLock)
{
if (ReferenceEquals(_currentSaveCts, myCts))
{
_currentSaveCts.Dispose();
_currentSaveCts = null;
}
}
myCts.Dispose();
}
}
private async UniTask SaveGameDataInternal(CancellationToken ct)
{
if ((DateTime.UtcNow - _lastSaveTime).TotalMilliseconds < 250)
{
Debug.Log("[SaveManager] Last save within 250ms, skipping.");
return;
}
if (CurrentSave == null)
CurrentSave = new SaveGame { SaveFileName = "NewGame" };
// NEW: let systems write into CurrentSave before the clone is made
await InvokeBeforeSaveRequestedAsync();
var saveClone = CurrentSave.DeepClone();
OnSaveRequested?.Invoke(saveClone);
Debug.Log($"[SaveManager] After OnSaveRequested, clone inventory count = {saveClone.InventoryData?.Count ?? 0}");
byte[] binaryData = null;
await UniTask.SwitchToThreadPool();
try
{
binaryData = MemoryPackSerializer.Serialize(saveClone);
}
finally
{
await UniTask.SwitchToMainThread(ct);
}
if (binaryData == null || binaryData.Length == 0)
{
Debug.LogWarning("[SaveManager] Empty serialized data, skipping save.");
return;
}
var saveDir = FilePaths.SaveDataFolder;
var backupDir = FilePaths.SaveBackupDataFolder;
Directory.CreateDirectory(saveDir);
Directory.CreateDirectory(backupDir);
var saveFileName = CurrentSave.SaveFileName + ".sav";
var saveFilePath = Path.Combine(saveDir, saveFileName);
var backupFilePath = Path.Combine(backupDir, saveFileName);
var tempSavePath = saveFilePath + ".tmp";
var attempt = 0;
var success = false;
while (attempt < MAX_RETRY_COUNT && !success)
{
attempt++;
try
{
await File.WriteAllBytesAsync(tempSavePath, binaryData, ct);
if (File.Exists(saveFilePath))
File.Replace(tempSavePath, saveFilePath, backupFilePath);
else
File.Move(tempSavePath, saveFilePath);
success = true;
}
catch (Exception ex)
{
Debug.LogError($"[SaveManager] Save attempt {attempt} failed: {ex}");
await UniTask.Delay(RETRY_DELAY_MS, cancellationToken: ct);
}
}
if (!success)
{
Debug.LogError("[SaveManager] All save attempts failed, save aborted.");
return;
}
CurrentSave = saveClone;
IsGameLoaded = true;
_lastSaveTime = DateTime.UtcNow;
OnSaveGameSaved?.Invoke();
Debug.Log($"[SaveManager] Save complete: {CurrentSave.SaveFileName}");
}
private async UniTask InvokeBeforeSaveRequestedAsync()
{
var handlers = OnBeforeSaveRequestedAsync;
if (handlers == null)
return;
foreach (Func<UniTask> handler in handlers.GetInvocationList())
{
try
{
await handler();
}
catch (Exception ex)
{
Debug.LogError($"[SaveManager] OnBeforeSaveRequestedAsync handler failed: {ex}");
}
}
}
public async UniTask CreateNewSaveGame(string saveFileName)
{
CurrentSave = new SaveGame
{
SaveFileName = saveFileName,
SaveVersion = "0.0.1a",
OpeningCinematicPlayed = false
};
IsGameLoaded = true;
OnSaveGameLoaded?.Invoke(CurrentSave);
await SaveGameDataLatest();
}
public List<(string FileName, DateTime LastModified)> GetAvailableSaves()
{
var saveDir = FilePaths.SaveDataFolder;
if (!Directory.Exists(saveDir)) return new List<(string, DateTime)>();
try
{
return Directory.GetFiles(saveDir, "*.sav")
.Select(file => (Path.GetFileNameWithoutExtension(file),
File.GetLastWriteTimeUtc(file))
)
.OrderByDescending(x => x.Item2)
.ToList();
}
catch (Exception e)
{
Debug.LogError($"[SaveManager] Failed to enumerate saves: {e.Message}");
return new List<(string, DateTime)>();
}
}
public async UniTask LoadLatestSave()
{
var available = GetAvailableSaves();
if (available.Count > 0)
{
await LoadGameData(available[0].FileName);
}
else
{
Debug.LogWarning("[SaveManager] No save files found. Creating new game.");
await CreateNewSaveGame("NewGame");
}
}
public async UniTask LoadGameData(string saveFileName)
{
var mainPath = Path.Combine(FilePaths.SaveDataFolder, saveFileName + ".sav");
var backupPath = Path.Combine(FilePaths.SaveBackupDataFolder, saveFileName + ".sav");
var loadedSave = await LoadFromFileAsync(mainPath);
if (loadedSave == null)
{
Debug.LogWarning($"[SaveManager] Main save load failed, trying backup: {saveFileName}");
loadedSave = await LoadFromFileAsync(backupPath);
if (loadedSave != null)
{
CurrentSave = loadedSave;
await SaveGameDataLatest();
Debug.Log("[SaveManager] Restored save from backup.");
}
}
CurrentSave = loadedSave ?? new SaveGame { SaveFileName = saveFileName, SaveVersion = "0.0.1-Pre-Alpha" };
Debug.Log($"[SaveManager] Loading save {loadedSave?.SaveFileName}");
IsGameLoaded = true;
OnSaveGameLoaded?.Invoke(CurrentSave);
}
private async UniTask<SaveGame> LoadFromFileAsync(string path)
{
if (string.IsNullOrEmpty(path) || !File.Exists(path)) return null;
try
{
var bytes = await File.ReadAllBytesAsync(path);
if (bytes == null || bytes.Length == 0) return null;
await UniTask.SwitchToThreadPool();
SaveGame result = null;
try
{
result = MemoryPackSerializer.Deserialize<SaveGame>(bytes);
}
finally
{
await UniTask.SwitchToMainThread();
}
return result;
}
catch (Exception e)
{
Debug.LogError($"[SaveManager] Failed to load or deserialize '{path}': {e}");
return null;
}
}
public bool Delete(string saveFileName)
{
if (string.IsNullOrWhiteSpace(saveFileName))
return false;
var normalizedName = Path.GetFileNameWithoutExtension(saveFileName);
var mainPath = Path.Combine(FilePaths.SaveDataFolder, normalizedName + ".sav");
var backupPath = Path.Combine(FilePaths.SaveBackupDataFolder, normalizedName + ".sav");
var deletedAny = false;
try
{
if (File.Exists(mainPath))
{
File.Delete(mainPath);
deletedAny = true;
}
}
catch (Exception ex)
{
Debug.LogError($"[SaveManager] Failed to delete main save '{mainPath}': {ex}");
}
try
{
if (File.Exists(backupPath))
{
File.Delete(backupPath);
deletedAny = true;
}
}
catch (Exception ex)
{
Debug.LogError($"[SaveManager] Failed to delete backup save '{backupPath}': {ex}");
}
if (CurrentSave != null &&
string.Equals(CurrentSave.SaveFileName, normalizedName, StringComparison.Ordinal))
{
CurrentSave = null;
IsGameLoaded = false;
}
return deletedAny;
}
public bool DoesSaveExist(string saveInfoFileName)
{
if (string.IsNullOrWhiteSpace(saveInfoFileName))
return false;
var mainPath = Path.Combine(FilePaths.SaveDataFolder, saveInfoFileName + ".sav");
var backupPath = Path.Combine(FilePaths.SaveBackupDataFolder, saveInfoFileName + ".sav");
try
{
return File.Exists(mainPath) || File.Exists(backupPath);
}
catch (Exception ex)
{
Debug.LogError($"[SaveManager] Error checking save existence for '{saveInfoFileName}': {ex}");
return false;
}
}
public void SetLevelFlag(LevelFlag levelFlag, bool value, bool requestSave = true)
{
if (CurrentSave?.PersistentVariables?.Game == null)
{
Debug.LogWarning($"[SaveManager] Could not set level flag '{levelFlag}' because CurrentSave or PersistentVariables.Game is null.");
return;
}
CurrentSave.PersistentVariables.Game.SetLevelFlag(levelFlag, value);
if (requestSave)
_eventCoordinator.PublishImmediate(new RequestGameSaveEvent());
}
public void SetPuzzleCompleted(PuzzleKey puzzleKey, bool value, bool requestSave = true)
{
if (CurrentSave?.PersistentVariables?.Game == null)
{
Debug.LogWarning($"[SaveManager] Could not set level flag '{puzzleKey}' because CurrentSave or PersistentVariables.Game is null.");
return;
}
CurrentSave.PersistentVariables.Game.SetPuzzleCompleted(puzzleKey, value);
if (requestSave)
_eventCoordinator.PublishImmediate(new RequestGameSaveEvent());
}
public bool GetLevelFlag(LevelFlag levelFlag)
{
if (CurrentSave?.PersistentVariables?.Game == null)
{
Debug.LogWarning($"[SaveManager] Could not get level flag '{levelFlag}' because CurrentSave or PersistentVariables.Game is null.");
return false;
}
return CurrentSave.PersistentVariables.Game.GetLevelFlag(levelFlag);
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 8f1f9b48ff6442b0ab094380f3d83f0b
timeCreated: 1769701587

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 4502fb509ddf4731bcc567b0f3b45259
timeCreated: 1770055219

Binary file not shown.

View File

@@ -0,0 +1,560 @@
using System;
using System.Collections.Generic;
using BriarQueen.Framework.Coordinators.Events;
using BriarQueen.Framework.Events.Gameplay;
using BriarQueen.Framework.Events.Input;
using BriarQueen.Framework.Events.UI;
using BriarQueen.Framework.Managers.UI;
using BriarQueen.Framework.Services.Game;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.UI;
using VContainer;
namespace BriarQueen.Framework.Managers.Input
{
public enum DeviceInputType
{
KeyboardAndMouse,
XboxController,
PlaystationController,
SwitchProController
}
public class InputManager : MonoBehaviour, IDisposable, IManager
{
[Header("Input")]
[SerializeField]
private PlayerInput _playerInput;
[Header("Virtual Cursor")]
[SerializeField]
private float _controllerCursorSpeed = 1200f;
[SerializeField]
private bool _clampCursorToScreen = true;
private readonly Dictionary<string, InputAction> _cachedByName = new();
private InputAction _clickAction;
private InputAction _codexAction;
private bool _codexShown;
private bool _toolScreenShown;
private GameService _gameService;
private EventCoordinator _eventCoordinator;
private InputAction _hideHudAction;
private bool _hudHidden;
private bool _initialized;
private bool _isPaused;
private InputAction _pauseAction;
private InputAction _pointAction;
private InputAction _rightClickAction;
private InputAction _openToolsAction;
private InputAction _nextToolAction;
private InputAction _previousToolAction;
private InputAction _nextItemAction;
private InputAction _previousItemAction;
private InputAction _virtualMouseAction;
private InputAction _submitAction;
private UICursorService _uiCursorService;
private Vector2 _controllerCursorInput;
public Vector2 PointerPosition { get; private set; }
public DeviceInputType DeviceInputType { get; private set; }
public string ActiveActionMap => _playerInput != null && _playerInput.currentActionMap != null
? _playerInput.currentActionMap.name
: string.Empty;
public bool Initialized => _initialized;
public bool IsPaused => _isPaused;
public bool UsingControllerCursor => DeviceInputType != DeviceInputType.KeyboardAndMouse;
private void Awake()
{
PointerPosition = new Vector2(Screen.width * 0.5f, Screen.height * 0.5f);
}
private void Update()
{
if (!_initialized)
return;
UpdateControllerCursor();
if (_uiCursorService != null && UsingControllerCursor)
_uiCursorService.SetVirtualCursorPosition(PointerPosition);
}
private void OnDestroy()
{
Dispose();
}
public void Dispose()
{
if (!_initialized)
return;
if (_eventCoordinator != null)
{
_eventCoordinator.Unsubscribe<UIToggleHudEvent>(OnHudStateChanged);
_eventCoordinator.Unsubscribe<ToggleCodexEvent>(OnCodexStateChanged);
_eventCoordinator.Unsubscribe<ToggleToolScreenEvent>(OnToolScreenStateChanged);
}
UnbindCoreInputs();
_initialized = false;
}
[Inject]
public void Construct(
EventCoordinator eventCoordinator,
UICursorService uiCursorService,
GameService gameService)
{
_eventCoordinator = eventCoordinator;
_uiCursorService = uiCursorService;
_gameService = gameService;
}
public void Initialize()
{
Debug.Log("[InputManager] Initialize called");
if (_initialized)
{
Debug.Log("[InputManager] Already initialized");
return;
}
if (_eventCoordinator == null)
{
Debug.LogWarning("[InputManager] EventCoordinator is null");
return;
}
if (_playerInput == null)
{
Debug.LogWarning("[InputManager] PlayerInput is null");
return;
}
if (_playerInput.actions == null)
{
Debug.LogWarning("[InputManager] PlayerInput.actions is null");
return;
}
Debug.Log($"[InputManager] Current map before cache: {ActiveActionMap}");
CacheActions();
Debug.Log($"[InputManager] Point action: {_pointAction}");
Debug.Log($"[InputManager] Click action: {_clickAction}");
Debug.Log($"[InputManager] Virtual_Mouse action: {_virtualMouseAction}");
BindCoreInputs();
DeviceInputType = GetDeviceInputType(_playerInput);
ApplyCursorModeForCurrentScheme();
_initialized = true;
Debug.Log("[InputManager] Initialization complete");
}
private void CacheActions()
{
_cachedByName.Clear();
_pointAction = CacheAction("Point");
_virtualMouseAction = CacheAction("Virtual_Mouse");
_pauseAction = CacheAction("Pause");
_clickAction = CacheAction("Click");
_rightClickAction = CacheAction("Right_Click");
_hideHudAction = CacheAction("Hide_HUD");
_codexAction = CacheAction("Codex");
_openToolsAction = CacheAction("Show_Tools");
_nextToolAction = CacheAction("Next_Tool");
_previousToolAction = CacheAction("Previous_Tool");
_nextItemAction = CacheAction("Next_Item");
_previousItemAction = CacheAction("Previous_Item");
_submitAction = CacheAction("Submit");
}
private InputAction CacheAction(string actionName)
{
var action = GetAction(actionName);
if (action != null && !string.IsNullOrWhiteSpace(action.name))
_cachedByName[action.name] = action;
return action;
}
private void BindCoreInputs()
{
if (_pointAction != null)
{
Debug.Log("[InputManager] Binding Point");
_pointAction.performed += OnPoint;
_pointAction.canceled += OnPoint;
}
else
{
Debug.LogWarning("[InputManager] Required action 'Point' not found.");
}
if (_virtualMouseAction != null)
{
_virtualMouseAction.performed += OnVirtualMouse;
_virtualMouseAction.canceled += OnVirtualMouse;
}
if (_pauseAction != null)
_pauseAction.performed += OnPause;
else
Debug.LogWarning("[InputManager] Action 'Pause' not found.");
if (_clickAction != null)
_clickAction.performed += OnClick;
else
Debug.LogWarning("[InputManager] Action 'Click' not found.");
if (_rightClickAction != null)
_rightClickAction.performed += OnRightClick;
else
Debug.LogWarning("[InputManager] Action 'Right_Click' not found.");
if (_hideHudAction != null)
_hideHudAction.performed += OnHideHUD;
else
Debug.LogWarning("[InputManager] Action 'Hide_HUD' not found.");
if (_codexAction != null)
_codexAction.performed += OnCodex;
else
Debug.LogWarning("[InputManager] Action 'Codex' not found.");
if (_openToolsAction != null)
_openToolsAction.performed += OnOpenTools;
else
Debug.LogWarning("[InputManager] Action 'Show_Tools' not found.");
if (_nextToolAction != null)
_nextToolAction.performed += OnNextToolClicked;
else
Debug.LogWarning("[InputManager] Action 'Next_Tool' not found.");
if (_previousToolAction != null)
_previousToolAction.performed += OnPreviousToolClicked;
else
Debug.LogWarning("[InputManager] Action 'Previous_Tool' not found.");
if (_nextItemAction != null)
_nextItemAction.performed += OnNextItemClicked;
else
Debug.LogWarning("[InputManager] Action 'Next_Item' not found.");
if (_previousItemAction != null)
_previousItemAction.performed += OnPreviousItemClicked;
else
Debug.LogWarning("[InputManager] Action 'Previous_Item' not found.");
if (_playerInput != null)
_playerInput.onControlsChanged += OnControlsChanged;
}
private void UnbindCoreInputs()
{
if (_pointAction != null)
{
_pointAction.performed -= OnPoint;
_pointAction.canceled -= OnPoint;
}
if (_virtualMouseAction != null)
{
_virtualMouseAction.performed -= OnVirtualMouse;
_virtualMouseAction.canceled -= OnVirtualMouse;
}
if (_pauseAction != null)
_pauseAction.performed -= OnPause;
if (_clickAction != null)
_clickAction.performed -= OnClick;
if (_rightClickAction != null)
_rightClickAction.performed -= OnRightClick;
if (_hideHudAction != null)
_hideHudAction.performed -= OnHideHUD;
if (_codexAction != null)
_codexAction.performed -= OnCodex;
if (_openToolsAction != null)
_openToolsAction.performed -= OnOpenTools;
if (_nextToolAction != null)
_nextToolAction.performed -= OnNextToolClicked;
if (_previousToolAction != null)
_previousToolAction.performed -= OnPreviousToolClicked;
if (_nextItemAction != null)
_nextItemAction.performed -= OnNextItemClicked;
if (_previousItemAction != null)
_previousItemAction.performed -= OnPreviousItemClicked;
if (_playerInput != null)
_playerInput.onControlsChanged -= OnControlsChanged;
}
private void UpdateControllerCursor()
{
if (!UsingControllerCursor)
return;
if (_controllerCursorInput.sqrMagnitude <= 0.0001f)
return;
PointerPosition += _controllerCursorInput * (_controllerCursorSpeed * Time.unscaledDeltaTime);
if (_clampCursorToScreen)
{
PointerPosition = new Vector2(
Mathf.Clamp(PointerPosition.x, 0f, Screen.width),
Mathf.Clamp(PointerPosition.y, 0f, Screen.height)
);
}
}
private void OnControlsChanged(PlayerInput playerInput)
{ Debug.Log($"Controls changed. Scheme: {playerInput.currentControlScheme}");
DeviceInputType = GetDeviceInputType(playerInput);
ApplyCursorModeForCurrentScheme();
}
private void ApplyCursorModeForCurrentScheme()
{
if (_uiCursorService == null)
return;
var useVirtualCursor = UsingControllerCursor;
_uiCursorService.SetUseVirtualCursor(useVirtualCursor);
if (useVirtualCursor)
_uiCursorService.SetVirtualCursorPosition(PointerPosition);
}
public void BindPauseForSkip(Action<InputAction.CallbackContext> callback)
{
if (_pauseAction == null || callback == null)
return;
_pauseAction.performed -= OnPause;
_pauseAction.performed += callback;
}
public void ResetPauseBind(Action<InputAction.CallbackContext> callback)
{
if (_pauseAction == null || callback == null)
return;
_pauseAction.performed -= callback;
_pauseAction.performed -= OnPause;
_pauseAction.performed += OnPause;
}
public void BindSubmitForStart(Action<InputAction.CallbackContext> callback)
{
if (_submitAction == null || callback == null)
return;
_submitAction.performed += callback;
}
public void ResetSubmitBind(Action<InputAction.CallbackContext> callback)
{
if (_submitAction == null || callback == null)
return;
_submitAction.performed -= callback;
}
public void BindAction(string actionName, Action<InputAction.CallbackContext> callback)
{
if (callback == null)
return;
var action = GetCachedAction(actionName);
if (action == null)
{
Debug.LogWarning($"[InputManager] Action '{actionName}' not found.");
return;
}
action.performed -= callback;
action.performed += callback;
}
public void UnbindAction(string actionName, Action<InputAction.CallbackContext> callback)
{
if (callback == null)
return;
var action = GetCachedAction(actionName);
if (action == null)
{
Debug.LogWarning($"[InputManager] Action '{actionName}' not found.");
return;
}
action.performed -= callback;
}
private InputAction GetCachedAction(string actionName)
{
if (string.IsNullOrWhiteSpace(actionName))
return null;
if (_cachedByName.TryGetValue(actionName, out var cached))
return cached;
var action = GetAction(actionName);
if (action != null)
_cachedByName[actionName] = action;
return action;
}
private static DeviceInputType GetDeviceInputType(PlayerInput input)
{
if (input == null)
return DeviceInputType.KeyboardAndMouse;
return input.currentControlScheme switch
{
"Keyboard&Mouse" => DeviceInputType.KeyboardAndMouse,
"Xbox" => DeviceInputType.XboxController,
"Playstation" => DeviceInputType.PlaystationController,
"Switch Pro" => DeviceInputType.SwitchProController,
_ => DeviceInputType.KeyboardAndMouse
};
}
private void OnHudStateChanged(UIToggleHudEvent evt)
{
_hudHidden = !evt.Show;
}
private void OnCodexStateChanged(ToggleCodexEvent evt)
{
_codexShown = evt.Shown;
}
private void OnToolScreenStateChanged(ToggleToolScreenEvent evt)
{
_toolScreenShown = evt.Shown;
}
private void OnHideHUD(InputAction.CallbackContext ctx)
{
_hudHidden = !_hudHidden;
_eventCoordinator?.PublishImmediate(new UIToggleHudEvent(_hudHidden));
}
private void OnPause(InputAction.CallbackContext ctx)
{
if(_gameService.IsMainMenuSceneLoaded)
return;
_isPaused = !_isPaused;
_eventCoordinator?.Publish(new PauseButtonClickedEvent());
}
private void OnCodex(InputAction.CallbackContext ctx)
{
if(_gameService.IsMainMenuSceneLoaded)
return;
_codexShown = !_codexShown;
_eventCoordinator?.Publish(new ToggleCodexEvent(_codexShown));
}
private void OnClick(InputAction.CallbackContext ctx)
{
_eventCoordinator?.PublishImmediate(new OnClickEvent(ctx));
}
private void OnRightClick(InputAction.CallbackContext ctx)
{
_eventCoordinator?.PublishImmediate(new OnRightClickEvent(ctx));
}
private void OnPoint(InputAction.CallbackContext ctx)
{
PointerPosition = ctx.ReadValue<Vector2>();
}
private void OnVirtualMouse(InputAction.CallbackContext ctx)
{
_controllerCursorInput = ctx.ReadValue<Vector2>();
}
private void OnOpenTools(InputAction.CallbackContext ctx)
{
if(_gameService.IsMainMenuSceneLoaded)
return;
_toolScreenShown = !_toolScreenShown;
_eventCoordinator?.Publish(new ToggleToolScreenEvent(_toolScreenShown));
}
private void OnNextToolClicked(InputAction.CallbackContext ctx)
{
_eventCoordinator?.Publish(new OnNextToolChangedEvent());
}
private void OnPreviousToolClicked(InputAction.CallbackContext ctx)
{
_eventCoordinator?.Publish(new OnPreviousToolChangedEvent());
}
private void OnNextItemClicked(InputAction.CallbackContext ctx)
{
_eventCoordinator?.Publish(new OnNextItemClickedEvent());
}
private void OnPreviousItemClicked(InputAction.CallbackContext ctx)
{
_eventCoordinator?.Publish(new OnPreviousItemClickedEvent());
}
public InputAction GetAction(string actionName)
{
if (_playerInput == null || _playerInput.actions == null || string.IsNullOrWhiteSpace(actionName))
return null;
try
{
return _playerInput.actions.FindAction(actionName, true);
}
catch
{
return null;
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: bd6a30bb8344415299513beb6108b2ad
timeCreated: 1770055219

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: ccf6b34d51dc4839b574a41a9ef8f834
timeCreated: 1769719788

Binary file not shown.

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c9707e08d4c234e9ea90b934d4760382
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

View File

@@ -0,0 +1,43 @@
using System;
using BriarQueen.Framework.Coordinators.Events;
using BriarQueen.Framework.Managers.Player;
using BriarQueen.Framework.Managers.Player.Data;
using Cysharp.Threading.Tasks;
namespace BriarQueen.Framework.Managers.Interaction.Data
{
/// <summary>
/// Inventory-safe per-item logic. Serialized INSIDE ItemDataSo via SerializeReference.
/// No scene objects required.
/// </summary>
[Serializable]
public abstract class BaseInteraction
{
/// <summary>
/// Called when player uses 'self' on 'other' from the inventory UI.
/// Return true if the interaction was handled (success OR "can't" message shown).
/// Return false if this interaction doesn't apply, so caller may try other routes/fallback.
/// </summary>
public abstract UniTask<bool> TryUseWith(
ItemDataSo self,
ItemDataSo other,
PlayerManager playerManager,
EventCoordinator eventCoordinator);
}
/// <summary>
/// Default "does nothing" interaction.
/// </summary>
[Serializable]
public sealed class NoOpItemInteraction : BaseInteraction
{
public override UniTask<bool> TryUseWith(
ItemDataSo self,
ItemDataSo other,
PlayerManager playerManager,
EventCoordinator eventCoordinator)
{
return UniTask.FromResult(false);
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 8ab49b601a434a30a12dd1e3fe82a282
timeCreated: 1771171646

View File

@@ -0,0 +1,18 @@
using BriarQueen.Framework.Managers.Player.Data;
using BriarQueen.Framework.Managers.UI;
using Cysharp.Threading.Tasks;
namespace BriarQueen.Framework.Managers.Interaction.Data
{
public interface IInteractable
{
UICursorService.CursorStyle ApplicableCursorStyle { get; }
string InteractableName { get; }
UniTask OnInteract(ItemDataSo item = null);
UniTask EnterHover();
UniTask ExitHover();
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: aaad7fee39064af5aa7bd0096e87065a
timeCreated: 1769717587

View File

@@ -0,0 +1,452 @@
using System;
using System.Collections.Generic;
using System.Threading;
using BriarQueen.Framework.Coordinators.Events;
using BriarQueen.Framework.Events.Gameplay;
using BriarQueen.Framework.Events.Input;
using BriarQueen.Framework.Events.UI;
using BriarQueen.Framework.Managers.Input;
using BriarQueen.Framework.Managers.Interaction.Data;
using BriarQueen.Framework.Managers.Player.Data;
using BriarQueen.Framework.Managers.UI;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
using VContainer;
namespace BriarQueen.Framework.Managers.Interaction
{
/// <summary>
/// Unified interaction service (World + UI):
/// - One hover loop
/// - One interactable interface
/// - UI hover takes priority over world hover
/// - Any topmost UI blocks lower UI/world interaction
/// - Supports modal/exclusive UI raycasters without disabling canvases
/// </summary>
public sealed class InteractManager : IDisposable, IManager
{
private const float HOVER_INTERVAL_SECONDS = 0.05f; // 20Hz
private const float POINTER_MOVE_THRESHOLD_SQR = 4f; // pixels^2
private const float MAX_RAY_DISTANCE = 250f;
private readonly EventCoordinator _eventCoordinator;
private readonly EventSystem _eventSystem;
private readonly InputManager _inputManager;
// World raycasting
private readonly LayerMask _layerMask = ~0;
private readonly PointerEventData _uiPointerEventData;
// Scene-bound UI raycasters
private readonly List<GraphicRaycaster> _uiRaycasters = new(8);
private readonly List<RaycastResult> _uiResults = new(32);
private readonly RaycastHit[] _worldHits = new RaycastHit[8];
private IInteractable _currentHovered;
private bool _disposed;
public bool Initialized { get; private set; }
/// <summary>
/// If set, only this raycaster is allowed to receive UI interaction.
/// Rendering is unaffected; this is input-only.
/// </summary>
private GraphicRaycaster _exclusiveRaycaster;
private CancellationTokenSource _hoverCts;
private Vector2 _lastPointerPos;
private ItemDataSo _selectedItem;
[Inject]
public InteractManager(
EventCoordinator eventCoordinator,
InputManager inputManager,
EventSystem eventSystem)
{
_eventCoordinator = eventCoordinator;
_inputManager = inputManager;
_eventSystem = eventSystem;
_uiPointerEventData = _eventSystem != null
? new PointerEventData(_eventSystem)
: null;
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_eventCoordinator.Unsubscribe<SelectedItemChangedEvent>(OnSelectedItemChanged);
_eventCoordinator.Unsubscribe<OnClickEvent>(OnClickReceived);
_eventCoordinator.Unsubscribe<OnRightClickEvent>(OnRightClickReceived);
_hoverCts?.Cancel();
_hoverCts?.Dispose();
_currentHovered = null;
_exclusiveRaycaster = null;
_eventCoordinator.Publish(new HoverInteractableChangedEvent(null));
_eventCoordinator.Publish(
new CursorStyleChangeEvent(
UICursorService.CursorStyle.Default));
}
/// <summary>
/// Assign the active UI GraphicRaycaster (legacy compatibility).
/// This replaces the full registered list.
/// </summary>
public void SetUIRaycaster(GraphicRaycaster raycaster)
{
_uiRaycasters.Clear();
_uiResults.Clear();
if (raycaster != null)
_uiRaycasters.Add(raycaster);
if (_exclusiveRaycaster != null && _exclusiveRaycaster != raycaster)
_exclusiveRaycaster = null;
if (_uiRaycasters.Count == 0 && _currentHovered != null)
ClearHover().Forget();
}
/// <summary>
/// Add another UI GraphicRaycaster.
/// Safe to call multiple times; duplicates are ignored.
/// </summary>
public void AddUIRaycaster(GraphicRaycaster raycaster)
{
if (raycaster == null)
return;
if (!_uiRaycasters.Contains(raycaster))
_uiRaycasters.Add(raycaster);
}
/// <summary>
/// Remove a UI GraphicRaycaster.
/// </summary>
public void RemoveUIRaycaster(GraphicRaycaster raycaster)
{
if (raycaster == null)
return;
_uiRaycasters.Remove(raycaster);
_uiResults.Clear();
if (_exclusiveRaycaster == raycaster)
_exclusiveRaycaster = null;
if (_uiRaycasters.Count == 0 && _currentHovered != null)
ClearHover().Forget();
}
/// <summary>
/// Restrict UI interaction to a single raycaster.
/// Useful for modal windows that should block all lower UI without disabling canvases.
/// </summary>
public void SetExclusiveRaycaster(GraphicRaycaster raycaster)
{
_exclusiveRaycaster = raycaster;
if (_currentHovered != null)
ClearHover().Forget();
}
/// <summary>
/// Clear exclusive mode and return to using all registered raycasters.
/// </summary>
public void ClearExclusiveRaycaster()
{
_exclusiveRaycaster = null;
if (_currentHovered != null)
ClearHover().Forget();
}
public void Initialize()
{
Debug.Log("[InteractManager] Initializing...");
_eventCoordinator.Subscribe<SelectedItemChangedEvent>(OnSelectedItemChanged);
_eventCoordinator.Subscribe<OnClickEvent>(OnClickReceived);
_eventCoordinator.Subscribe<OnRightClickEvent>(OnRightClickReceived);
StartHoverLoop();
Debug.Log("[InteractManager] Initialized.");
Initialized = true;
}
private void StartHoverLoop()
{
_hoverCts?.Cancel();
_hoverCts?.Dispose();
_hoverCts = new CancellationTokenSource();
_lastPointerPos = _inputManager.PointerPosition;
HoverLoop(_hoverCts.Token).Forget();
}
private async UniTaskVoid HoverLoop(CancellationToken token)
{
while (!token.IsCancellationRequested)
try
{
await UniTask.Delay(
TimeSpan.FromSeconds(HOVER_INTERVAL_SECONDS),
cancellationToken: token);
if (_disposed)
return;
var pointer = _inputManager.PointerPosition;
var delta = pointer - _lastPointerPos;
if (delta.sqrMagnitude < POINTER_MOVE_THRESHOLD_SQR && _currentHovered != null)
continue;
_lastPointerPos = pointer;
// Topmost UI always wins. If it is not interactable, it still blocks lower UI/world.
if (TryRaycastTopUI(pointer, out var uiHit))
{
if (uiHit != null)
await SetHovered(uiHit);
else
await ClearHover();
continue;
}
// World fallback only if no UI at all was hit.
var worldHit = RaycastWorld(pointer);
await SetHovered(worldHit);
}
catch (OperationCanceledException)
{
return;
}
catch (Exception ex)
{
Debug.LogWarning($"[InteractManager] Hover loop error: {ex}");
}
}
private bool TryRaycastTopUI(Vector2 pointer, out IInteractable interactable)
{
interactable = null;
if (_uiPointerEventData == null)
return false;
_uiPointerEventData.Reset();
_uiPointerEventData.position = pointer;
_uiResults.Clear();
if (_exclusiveRaycaster != null)
{
if (!_exclusiveRaycaster.isActiveAndEnabled)
return false;
_exclusiveRaycaster.Raycast(_uiPointerEventData, _uiResults);
}
else
{
if (_uiRaycasters.Count == 0)
return false;
for (var r = 0; r < _uiRaycasters.Count; r++)
{
var raycaster = _uiRaycasters[r];
if (raycaster == null || !raycaster.isActiveAndEnabled)
continue;
raycaster.Raycast(_uiPointerEventData, _uiResults);
}
}
if (_uiResults.Count == 0)
return false;
_uiResults.Sort((a, b) =>
{
if (a.sortingLayer != b.sortingLayer)
return b.sortingLayer.CompareTo(a.sortingLayer);
if (a.sortingOrder != b.sortingOrder)
return b.sortingOrder.CompareTo(a.sortingOrder);
if (a.depth != b.depth)
return b.depth.CompareTo(a.depth);
if (!Mathf.Approximately(a.distance, b.distance))
return a.distance.CompareTo(b.distance);
return a.index.CompareTo(b.index);
});
foreach (var rr in _uiResults)
{
if (rr.gameObject == null)
continue;
var candidate = rr.gameObject.GetComponentInParent<IInteractable>();
if (candidate != null)
{
interactable = candidate;
return true;
}
}
return true; // UI was hit, but nothing interactable was found, so UI still blocks world
}
private static bool IsRaycastResultHigherPriority(RaycastResult candidate, RaycastResult currentBest)
{
if (candidate.sortingLayer != currentBest.sortingLayer)
return candidate.sortingLayer > currentBest.sortingLayer;
if (candidate.sortingOrder != currentBest.sortingOrder)
return candidate.sortingOrder > currentBest.sortingOrder;
if (candidate.depth != currentBest.depth)
return candidate.depth > currentBest.depth;
if (!Mathf.Approximately(candidate.distance, currentBest.distance))
return candidate.distance < currentBest.distance;
return candidate.index < currentBest.index;
}
private IInteractable RaycastWorld(Vector2 pointer)
{
var cam = Camera.main;
if (cam == null)
return null;
var ray = cam.ScreenPointToRay(pointer);
var count = Physics.RaycastNonAlloc(
ray,
_worldHits,
MAX_RAY_DISTANCE,
_layerMask,
QueryTriggerInteraction.Collide);
if (count <= 0)
return null;
var bestDist = float.PositiveInfinity;
IInteractable best = null;
for (var i = 0; i < count; i++)
{
var hit = _worldHits[i];
if (hit.collider == null)
continue;
var candidate = hit.collider.GetComponentInParent<IInteractable>();
if (candidate == null)
continue;
if (hit.distance < bestDist)
{
bestDist = hit.distance;
best = candidate;
}
}
return best;
}
private async UniTask SetHovered(IInteractable next)
{
if (ReferenceEquals(_currentHovered, next))
return;
if (_currentHovered != null)
try
{
await _currentHovered.ExitHover();
}
catch
{
}
_currentHovered = next;
_eventCoordinator.Publish(
new HoverInteractableChangedEvent(_currentHovered));
var cursor =
_currentHovered?.ApplicableCursorStyle
?? UICursorService.CursorStyle.Default;
_eventCoordinator.Publish(
new CursorStyleChangeEvent(cursor));
if (_currentHovered != null)
try
{
await _currentHovered.EnterHover();
}
catch
{
}
}
private async UniTask ClearHover()
{
if (_currentHovered == null)
return;
try
{
await _currentHovered.ExitHover();
}
catch
{
}
_currentHovered = null;
_eventCoordinator.Publish(
new HoverInteractableChangedEvent(null));
_eventCoordinator.Publish(
new CursorStyleChangeEvent(
UICursorService.CursorStyle.Default));
}
private void OnClickReceived(OnClickEvent evt)
{
if (_disposed)
return;
if (_currentHovered == null)
return;
_currentHovered.OnInteract(_selectedItem).Forget();
}
private void OnRightClickReceived(OnRightClickEvent obj)
{
_eventCoordinator.Publish(new SelectedItemChangedEvent(null));
}
private void OnSelectedItemChanged(SelectedItemChangedEvent evt)
{
_selectedItem = evt.Item;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: e536e3741ed64dbca4c9523a72211140
timeCreated: 1769719788

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 5f2a02cfae2940a4bbc18a73887c4ba5
timeCreated: 1769778109

Binary file not shown.

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: fe1cec78ab0b4a05bdafb735afcfab5a
timeCreated: 1773830332

View File

@@ -0,0 +1,243 @@
using System.Collections.Generic;
using System.Threading;
using BriarQueen.Data.Identifiers;
using BriarQueen.Data.IO.Saves;
using BriarQueen.Framework.Assets;
using BriarQueen.Framework.Coordinators.Events;
using BriarQueen.Framework.Events.Save;
using BriarQueen.Framework.Events.UI;
using BriarQueen.Framework.Managers.Audio;
using BriarQueen.Framework.Managers.Interaction.Data;
using BriarQueen.Framework.Managers.IO;
using BriarQueen.Framework.Managers.Player;
using BriarQueen.Framework.Managers.Player.Data;
using BriarQueen.Framework.Managers.UI;
using BriarQueen.Framework.Registries;
using BriarQueen.Framework.Services.Destruction;
using BriarQueen.Framework.Services.Settings;
using BriarQueen.Framework.Services.Tutorials;
using Cysharp.Threading.Tasks;
using PrimeTween;
using UnityEngine;
using VContainer;
namespace BriarQueen.Framework.Managers.Levels.Data
{
public class BaseItem : MonoBehaviour, IInteractable
{
[Header("Item Details")]
[SerializeField]
private ItemDataSo _itemData;
[Tooltip("Used for custom tooltip. Defaults to Item Name")]
[SerializeField]
private string _interactableTooltip = string.Empty;
[Header("Object Setup")]
[SerializeField]
protected CanvasGroup _canvasGroup;
protected AddressableManager AddressableManager;
protected AssetRegistry AssetRegistry;
protected DestructionService DestructionService;
protected EventCoordinator EventCoordinator;
protected CancellationTokenSource PickupCts;
protected Sequence PickupSequence;
protected PlayerManager PlayerManager;
protected SaveManager SaveManager;
protected SettingsService SettingsService;
protected TutorialService TutorialService;
protected AudioManager AudioManager;
public ItemDataSo ItemData => _itemData;
public CanvasGroup CanvasGroup => _canvasGroup;
protected string InteractableTooltip => _interactableTooltip;
public GameObject GameObject => gameObject;
private void Awake()
{
if (_canvasGroup == null)
_canvasGroup = GetComponent<CanvasGroup>();
}
public virtual UICursorService.CursorStyle ApplicableCursorStyle => UICursorService.CursorStyle.Pickup;
public virtual string InteractableName =>
!string.IsNullOrWhiteSpace(_interactableTooltip) ? _interactableTooltip : _itemData.ItemName;
/// <summary>
/// Called when the item is interacted with. Defaults to Pickup.
/// </summary>
/// <param name="item"></param>
public virtual async UniTask OnInteract(ItemDataSo item = null)
{
if (item != null)
{
EventCoordinator.Publish(new DisplayInteractEvent(InteractEventIDs.Get(ItemInteractKey.CantUseItem)));
return;
}
CheckCyclingTutorial();
if (!CheckEmptyHands())
return;
await Pickup();
await OnInteracted();
}
public virtual UniTask EnterHover()
{
return UniTask.CompletedTask;
}
public virtual UniTask ExitHover()
{
return UniTask.CompletedTask;
}
/// <summary>
/// Called when item is interacted with.
/// </summary>
/// <returns></returns>
protected virtual UniTask OnInteracted()
{
return UniTask.CompletedTask;
}
protected virtual bool CheckEmptyHands()
{
if (PlayerManager.GetEquippedTool() != ToolID.None)
{
EventCoordinator.Publish(new DisplayInteractEvent(InteractEventIDs.Get(ItemInteractKey.EmptyHands)));
return false;
}
return true;
}
private void CheckCyclingTutorial()
{
TutorialService.DisplayTutorial(TutorialPopupID.ItemCycling);
}
[Inject]
public void Construct(EventCoordinator eventCoordinator, SaveManager saveManager, PlayerManager playerManager,
DestructionService destructionService, SettingsService settingsService,
AddressableManager addressableManager, AssetRegistry assetRegistry, TutorialService tutorialService,
AudioManager audioManager)
{
EventCoordinator = eventCoordinator;
SaveManager = saveManager;
PlayerManager = playerManager;
DestructionService = destructionService;
SettingsService = settingsService;
AddressableManager = addressableManager;
AssetRegistry = assetRegistry;
TutorialService = tutorialService;
AudioManager = audioManager;
}
protected virtual async UniTask Remove()
{
// TODO - Play Cut Vines SFX
if (_canvasGroup == null) _canvasGroup = GetComponent<CanvasGroup>();
if (PickupSequence.isAlive)
{
PickupSequence.Complete();
PickupCts?.Cancel();
PickupCts?.Dispose();
}
PickupCts = new CancellationTokenSource();
PickupSequence = Sequence.Create().Group(Tween.Alpha(_canvasGroup, new TweenSettings<float>
{
startValue = _canvasGroup.alpha,
endValue = 0,
settings = new TweenSettings
{
duration = 0.5f
}
}));
try
{
await PickupSequence.ToUniTask(cancellationToken: PickupCts.Token);
await OnRemoved();
}
catch
{
}
finally
{
UpdateSaveGameOnRemoval();
await DestructionService.Destroy(gameObject);
}
}
private void UpdateSaveGameOnRemoval()
{
var save = SaveManager.CurrentSave;
Debug.Log($"[Base Item] Found save - {save.SaveFileName}");
save.RemovedItems ??= new List<ItemSaveData>();
save.RemovedItems.Add(new ItemSaveData
{
UniqueIdentifier = _itemData.UniqueID
});
EventCoordinator.PublishImmediate(new RequestGameSaveEvent());
}
protected virtual UniTask OnRemoved()
{
return UniTask.CompletedTask;
}
protected virtual async UniTask Pickup()
{
if (_canvasGroup == null) _canvasGroup = GetComponent<CanvasGroup>();
if (PickupSequence.isAlive)
{
PickupSequence.Complete();
PickupCts?.Cancel();
PickupCts?.Dispose();
}
PickupCts = new CancellationTokenSource();
PickupSequence = Sequence.Create().Group(Tween.Alpha(_canvasGroup, new TweenSettings<float>
{
startValue = _canvasGroup.alpha,
endValue = 0,
settings = new TweenSettings
{
duration = 0.5f
}
}));
try
{
await PickupSequence.ToUniTask(cancellationToken: PickupCts.Token);
}
catch
{
}
finally
{
PlayerManager.CollectItem(_itemData);
await DestructionService.Destroy(gameObject);
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 529a2a922f6f48a982159e2dbc41c542
timeCreated: 1770918505

View File

@@ -0,0 +1,116 @@
using System.Collections.Generic;
using BriarQueen.Data.Identifiers;
using BriarQueen.Framework.Coordinators.Events;
using BriarQueen.Framework.Events.UI;
using BriarQueen.Framework.Managers.Hints.Data;
using BriarQueen.Framework.Managers.Interaction;
using BriarQueen.Framework.Managers.IO;
using BriarQueen.Framework.Managers.Player;
using BriarQueen.Framework.Managers.Player.Data.Codex;
using BriarQueen.Framework.Services.Destruction;
using BriarQueen.Framework.Services.Settings;
using BriarQueen.Framework.Services.Tutorials;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.UI;
using VContainer;
namespace BriarQueen.Framework.Managers.Levels.Data
{
public class BaseLevel : MonoBehaviour
{
[Header("General")]
[SerializeField]
private LevelKey _levelKey;
[SerializeField]
private string _levelName;
[Header("Items")]
[SerializeField]
public List<BaseItem> Pickups;
public List<CodexTrigger> CodexTriggers;
[Header("Setup")]
[SerializeField]
protected GraphicRaycaster _raycaster;
protected DestructionService DestructionService;
protected EventCoordinator EventCoordinator;
protected InteractManager InteractManager;
protected PlayerManager PlayerManager;
protected SaveManager SaveManager;
protected SettingsService SettingsService;
protected TutorialService TutorialService;
public virtual string SceneID => AssetKeyIdentifiers.Get(SceneKey.GameScene);
public string LevelID => AssetKeyIdentifiers.Get(_levelKey);
public virtual string LevelName => _levelName;
public virtual bool IsPuzzleLevel { get; }
public virtual int CurrentLevelHintStage { get; set; }
public virtual Dictionary<int, BaseHint> Hints { get; }
[Inject]
public void Construct(EventCoordinator eventCoordinator, InteractManager interactManager, SaveManager saveManager,
DestructionService destructionService, SettingsService settingsService, PlayerManager playerManager,
TutorialService tutorialService)
{
EventCoordinator = eventCoordinator;
InteractManager = interactManager;
SaveManager = saveManager;
DestructionService = destructionService;
SettingsService = settingsService;
PlayerManager = playerManager;
TutorialService = tutorialService;
}
public async UniTask PostLoad()
{
InteractManager.AddUIRaycaster(_raycaster);
EventCoordinator.Publish(new UIToggleHudEvent(true));
await PostLoadInternal();
}
/// <summary>
/// Called after Level Load, but before activating. Override for Implementations
/// </summary>
/// <returns></returns>
protected virtual UniTask PostLoadInternal()
{
return UniTask.CompletedTask;
}
public async UniTask PostActivate()
{
await PostActivateInternal();
}
/// <summary>
/// Called after a level is activated. Override for implementations.
/// </summary>
/// <returns></returns>
protected virtual UniTask PostActivateInternal()
{
return UniTask.CompletedTask;
}
public async UniTask PreUnload()
{
InteractManager.RemoveUIRaycaster(_raycaster);
await PreUnloadInternal();
}
/// <summary>
/// Called before the level is destroyed. Override for cleanup, etc.
/// </summary>
/// <returns></returns>
protected virtual UniTask PreUnloadInternal()
{
return UniTask.CompletedTask;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 4ccd49cd2f5c4e8b9cfd394f7844bdf8
timeCreated: 1769725044

View File

@@ -0,0 +1,301 @@
using System;
using System.Collections.Generic;
using System.Linq;
using BriarQueen.Data.IO.Saves;
using BriarQueen.Framework.Assets;
using BriarQueen.Framework.Coordinators.Events;
using BriarQueen.Framework.Events.Gameplay;
using BriarQueen.Framework.Events.Progression;
using BriarQueen.Framework.Events.Save;
using BriarQueen.Framework.Events.UI;
using BriarQueen.Framework.Managers.IO;
using BriarQueen.Framework.Managers.Levels.Data;
using BriarQueen.Framework.Registries;
using BriarQueen.Framework.Services.Destruction;
using BriarQueen.Framework.Services.Puzzles;
using BriarQueen.Framework.Services.Puzzles.Base;
using Cysharp.Threading.Tasks;
using UnityEngine;
using VContainer;
namespace BriarQueen.Framework.Managers.Levels
{
public class LevelManager : IDisposable, IManager
{
private const float LEVEL_FADE_DURATION = 1f;
private readonly AddressableManager _addressableManager;
private readonly AssetRegistry _assetRegistry;
private readonly DestructionService _destructionService;
private readonly EventCoordinator _eventCoordinator;
private readonly object _lock = new();
private readonly PuzzleService _puzzleService;
private readonly SaveManager _saveManager;
private UniTask<bool> _activeLoadTask = UniTask.FromResult(false);
private BaseLevel _currentLevel;
public bool Initialized { get; private set; }
[Inject]
public LevelManager(
AddressableManager addressableManager,
AssetRegistry assetRegistry,
DestructionService destructionService,
EventCoordinator eventCoordinator,
SaveManager saveManager,
PuzzleService puzzleService)
{
_addressableManager = addressableManager;
_assetRegistry = assetRegistry;
_destructionService = destructionService;
_eventCoordinator = eventCoordinator;
_saveManager = saveManager;
_puzzleService = puzzleService;
}
public void Initialize()
{
if (Initialized)
return;
Debug.Log($"[{nameof(LevelManager)}] Initializing...");
_saveManager.OnSaveRequested += OnSaveGameRequested;
_eventCoordinator.Subscribe<UpdateHintProgressEvent>(OnHintStageUpdated);
Debug.Log($"[{nameof(LevelManager)}] Initialized.");
Initialized = true;
}
public void Dispose()
{
if (!Initialized)
return;
_saveManager.OnSaveRequested -= OnSaveGameRequested;
_eventCoordinator.Unsubscribe<UpdateHintProgressEvent>(OnHintStageUpdated);
Initialized = false;
}
private void OnHintStageUpdated(UpdateHintProgressEvent evt)
{
if (_currentLevel == null || evt == null)
return;
if (!string.Equals(evt.LevelID, _currentLevel.LevelID, StringComparison.Ordinal))
return;
var incoming = Mathf.Max(0, evt.Stage);
if (evt.Force)
{
_currentLevel.CurrentLevelHintStage = incoming;
return;
}
var current = Mathf.Max(0, _currentLevel.CurrentLevelHintStage);
_currentLevel.CurrentLevelHintStage = Mathf.Max(current, incoming);
}
private void OnSaveGameRequested(SaveGame saveGame)
{
if (saveGame == null || _currentLevel == null)
return;
saveGame.CurrentLevelID = _currentLevel.LevelID;
saveGame.CurrentSceneID = _currentLevel.SceneID;
saveGame.LevelHintStages ??= new Dictionary<string, int>();
saveGame.LevelHintStages[_currentLevel.LevelID] = Mathf.Max(0, _currentLevel.CurrentLevelHintStage);
}
public UniTask<bool> LoadLevel(string levelAssetID)
{
if (string.IsNullOrWhiteSpace(levelAssetID))
{
Debug.LogError("[LevelManager] LoadLevel called with null/empty levelAssetID.");
return UniTask.FromResult(false);
}
lock (_lock)
{
_activeLoadTask = LoadLevelInternal(levelAssetID);
return _activeLoadTask;
}
}
private async UniTask<bool> LoadLevelInternal(string levelAssetID)
{
try
{
if (_assetRegistry == null)
{
Debug.LogError("[LevelManager] AssetRegistry is null.");
return false;
}
if (!_assetRegistry.TryGetReference(levelAssetID, out var levelRef) || levelRef == null)
{
Debug.LogError($"[LevelManager] No level reference found for id '{levelAssetID}'.");
return false;
}
_eventCoordinator.PublishImmediate(new FadeEvent(false, LEVEL_FADE_DURATION));
await UniTask.Delay(TimeSpan.FromSeconds(LEVEL_FADE_DURATION));
if (_currentLevel != null)
await UnloadLevelInternal();
var levelObj = await _addressableManager.InstantiateAsync(levelRef);
if (levelObj == null)
{
Debug.LogError($"[LevelManager] Failed to instantiate level '{levelAssetID}'.");
return false;
}
var level = levelObj.GetComponent<BaseLevel>();
if (level == null)
{
Debug.LogError($"[LevelManager] Instantiated level '{levelAssetID}' has no BaseLevel component. Destroying instance.");
await _destructionService.Destroy(levelObj);
return false;
}
_currentLevel = level;
RestoreHintStageForCurrentLevel();
await RestoreItemStateForCurrentLevel();
await _currentLevel.PostLoad();
if (_currentLevel is BasePuzzle puzzle)
await _puzzleService.LoadPuzzle(puzzle);
_eventCoordinator.Publish(new LevelChangedEvent(_currentLevel));
_eventCoordinator.PublishImmediate(new FadeEvent(true, LEVEL_FADE_DURATION));
await UniTask.Delay(TimeSpan.FromSeconds(LEVEL_FADE_DURATION));
if (_currentLevel != null)
await _currentLevel.PostActivate();
_eventCoordinator.Publish(new RequestGameSaveEvent());
return true;
}
catch (Exception ex)
{
Debug.LogError($"[LevelManager] Exception while loading '{levelAssetID}': {ex}");
if (_currentLevel != null)
{
try
{
await _destructionService.Destroy(_currentLevel.gameObject);
}
catch (Exception destroyEx)
{
Debug.LogWarning($"[LevelManager] Failed to destroy broken level instance: {destroyEx}");
}
_currentLevel = null;
}
return false;
}
}
private void RestoreHintStageForCurrentLevel()
{
if (_currentLevel == null)
return;
var save = _saveManager.CurrentSave;
if (save?.LevelHintStages == null)
{
_currentLevel.CurrentLevelHintStage = 0;
return;
}
if (save.LevelHintStages.TryGetValue(_currentLevel.LevelID, out var stage))
_currentLevel.CurrentLevelHintStage = Mathf.Max(0, stage);
else
_currentLevel.CurrentLevelHintStage = 0;
}
private async UniTask RestoreItemStateForCurrentLevel()
{
if (_currentLevel == null)
return;
var save = _saveManager.CurrentSave;
if (save == null)
return;
var interactables = _currentLevel.Pickups;
if (interactables == null || interactables.Count == 0)
return;
foreach (var interactable in interactables)
{
if (interactable.ItemData == null)
{
Debug.Log($"[LevelManager] No Item Data for {interactable.InteractableName}");
continue;
}
if (save.CollectedItems.Any(x => x.UniqueIdentifier == interactable.ItemData.UniqueID))
await _destructionService.Destroy(interactable.gameObject);
if (save.RemovedItems.Any(x => x.UniqueIdentifier == interactable.ItemData.UniqueID))
await _destructionService.Destroy(interactable.gameObject);
}
var codexTriggers = _currentLevel.CodexTriggers;
foreach (var trigger in codexTriggers)
{
if (save.DiscoveredCodexEntries.Any(x => x.UniqueIdentifier == trigger.Entry.UniqueID))
{
if (trigger.RemoveTrigger)
await _destructionService.Destroy(trigger.gameObject);
}
}
}
public UniTask UnloadLevel()
{
lock (_lock)
{
if (_activeLoadTask.Status == UniTaskStatus.Pending)
return _activeLoadTask.ContinueWith(_ => UnloadLevelInternal());
return UnloadLevelInternal();
}
}
private async UniTask UnloadLevelInternal()
{
if (_currentLevel == null)
return;
var level = _currentLevel;
_currentLevel = null;
try
{
if (level is BasePuzzle puzzle)
await _puzzleService.SavePuzzle(puzzle);
_eventCoordinator.Publish(new RequestGameSaveEvent());
await level.PreUnload();
}
catch (Exception ex)
{
Debug.LogWarning($"[LevelManager] Exception in PreUnload of {level.name}: {ex}");
}
await _destructionService.Destroy(level.gameObject);
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 21e9be484b29431886214bcd47848292
timeCreated: 1769778109

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 84087611dabd4fb286680a7ed6b586d5
timeCreated: 1769703840

Binary file not shown.

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 650e923c83a4463ba1eebe0b4c85277c
timeCreated: 1773830133

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 58a00e94414846c6b914db0fbc38837f
timeCreated: 1773682720

View File

@@ -0,0 +1,87 @@
using System.Collections.Generic;
using System.Linq;
using BriarQueen.Data.Identifiers;
using UnityEngine;
namespace BriarQueen.Framework.Managers.Player.Data.Codex
{
public class Codex
{
private readonly List<CodexEntrySo> _entries = new();
public IReadOnlyList<CodexEntrySo> Entries => _entries;
public void AddEntry(CodexEntrySo entry)
{
if (entry == null)
return;
if (_entries.Any(x => x.UniqueID == entry.UniqueID))
return;
Debug.Log($"Adding codex entry {entry.UniqueID}");
_entries.Add(entry);
}
public void RemoveEntry(CodexEntrySo entry)
{
if (entry == null)
return;
if (_entries.All(x => x.UniqueID != entry.UniqueID))
return;
_entries.Remove(entry);
}
public void RemoveEntry(string uniqueID)
{
var entry = _entries.FirstOrDefault(x => x.UniqueID == uniqueID);
if (entry)
_entries.Remove(entry);
}
public bool HasEntry(CodexEntrySo entry)
{
if (entry == null)
return false;
return _entries.Any(x => x.UniqueID == entry.UniqueID);
}
public bool HasEntry(string uniqueID)
{
if (string.IsNullOrWhiteSpace(uniqueID))
return false;
return _entries.Any(x => x.UniqueID == uniqueID);
}
public IEnumerable<CodexEntrySo> GetEntriesByType(CodexType codexType)
{
return _entries.Where(x => x.EntryType == codexType);
}
public IEnumerable<CodexEntrySo> GetBookEntries()
{
return GetEntriesByType(CodexType.BookEntry);
}
public IEnumerable<CodexEntrySo> GetPuzzleClues()
{
return GetEntriesByType(CodexType.PuzzleClue);
}
public IEnumerable<CodexEntrySo> GetPhotoEntries()
{
return GetEntriesByType(CodexType.Photo);
}
public void ClearEntries()
{
_entries.Clear();
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 15993ce7dcfd437186874d9d0822ad2d
timeCreated: 1773682720

View File

@@ -0,0 +1,40 @@
using BriarQueen.Framework.Managers.Levels.Data;
using BriarQueen.Framework.Managers.UI;
using Cysharp.Threading.Tasks;
using UnityEngine;
namespace BriarQueen.Framework.Managers.Player.Data.Codex
{
public class CodexTrigger : BaseItem, ICodexTrigger
{
[Header("Codex")]
[SerializeField]
private CodexEntrySo _codexEntry;
[SerializeField]
private bool _removeTrigger;
public override UICursorService.CursorStyle ApplicableCursorStyle => UICursorService.CursorStyle.Inspect;
public override string InteractableName =>
!string.IsNullOrWhiteSpace(InteractableTooltip) ? InteractableTooltip : _codexEntry.Title;
public GameObject TriggerObject => GameObject;
public CodexEntrySo Entry => _codexEntry;
public bool RemoveTrigger => _removeTrigger;
public override async UniTask OnInteract(ItemDataSo item = null)
{
if (!CheckEmptyHands())
return;
PlayerManager.UnlockCodexEntry(_codexEntry);
if (_removeTrigger)
{
await Remove();
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 7b26357202d94390a9d04c7cd3194b40
timeCreated: 1773861326

View File

@@ -0,0 +1,13 @@
using Cysharp.Threading.Tasks;
using UnityEngine;
namespace BriarQueen.Framework.Managers.Player.Data.Codex
{
public interface ICodexTrigger
{
GameObject TriggerObject { get; }
CodexEntrySo Entry { get; }
bool RemoveTrigger { get; }
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 2f81e1754251482a9e9bc183c5e1bd5b
timeCreated: 1773868877

View File

@@ -0,0 +1,109 @@
using BriarQueen.Data.Identifiers;
using NaughtyAttributes;
using UnityEngine;
namespace BriarQueen.Framework.Managers.Player.Data
{
[CreateAssetMenu(menuName = "Briar Queen/Codex/New Codex Entry", fileName = "New Codex Entry")]
public class CodexEntrySo : ScriptableObject
{
[Header("Codex Type")]
[SerializeField]
private CodexType _codexType;
[Header("Codex ID")]
[SerializeField]
[ShowIf(nameof(IsBookEntry))]
private BookEntryID _bookEntryID;
[SerializeField]
[ShowIf(nameof(IsPuzzleClue))]
private ClueEntryID _clueEntryID;
[SerializeField]
[ShowIf(nameof(IsPhoto))]
private PhotoEntryID _photoEntryID;
[Header("Display")]
[SerializeField]
private string _title;
[SerializeField]
[ShowIf(EConditionOperator.Or, nameof(IsPhoto), nameof(_isPhotoOverride))]
private Sprite _displayImage;
[TextArea(6, 20)]
[SerializeField]
[ShowIf(EConditionOperator.Or, nameof(IsBookEntry), nameof(_bodyTextOverride))]
private string _bodyText;
[SerializeField]
[ShowIf(EConditionOperator.Or, nameof(IsPhoto), nameof(_isPhotoOverride))]
private string _photoDescription;
[SerializeField]
[ShowIf(EConditionOperator.Or, nameof(IsPhoto), nameof(_isPhotoOverride))]
private string _polaroidWriting;
[Header("Behaviour")]
[SerializeField]
private bool _startsUnlocked;
[SerializeField]
private bool _hiddenUntilUnlocked = true;
[SerializeField]
private bool _isPhotoOverride;
[SerializeField]
private bool _bodyTextOverride;
[SerializeField]
private Location _location;
public CodexType EntryType => _codexType;
public Location Location => _location;
public bool IsBookEntry => _codexType == CodexType.BookEntry;
public bool IsPuzzleClue => _codexType == CodexType.PuzzleClue;
public bool IsPhoto => _codexType == CodexType.Photo;
public BookEntryID BookEntryID => _bookEntryID;
public ClueEntryID ClueEntryID => _clueEntryID;
public PhotoEntryID PhotoEntryID => _photoEntryID;
public string Title => _title;
public Sprite DisplayImage => _displayImage;
public string BodyText => _bodyText;
public string PhotoDescription => _photoDescription;
public string PolaroidWriting => _polaroidWriting;
public bool StartsUnlocked => _startsUnlocked;
public bool HiddenUntilUnlocked => _hiddenUntilUnlocked;
public bool IsPhotoOverride => _isPhotoOverride;
public bool IsBodyTextOverride => _bodyTextOverride;
public string UniqueID
{
get
{
return _codexType switch
{
CodexType.BookEntry when _bookEntryID != BookEntryID.None =>
CodexEntryIDs.Get(_bookEntryID),
CodexType.PuzzleClue when _clueEntryID != ClueEntryID.None =>
CodexEntryIDs.Get(_clueEntryID),
CodexType.Photo when _photoEntryID != PhotoEntryID.None =>
CodexEntryIDs.Get(_photoEntryID),
_ => string.Empty
};
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 5bdd4c819cc240c1b89ef5a0e33145ef
timeCreated: 1773682436

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: a795285ea8e94689952d456af9369c92
timeCreated: 1769700038

View File

@@ -0,0 +1,43 @@
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
namespace BriarQueen.Framework.Managers.Player.Data.Inventory
{
public class Backpack
{
private readonly List<ItemDataSo> _items = new();
public IReadOnlyList<ItemDataSo> Items => _items;
public void AddItem(ItemDataSo item)
{
if (_items.Any(x => x.UniqueID == item.UniqueID))
return;
Debug.Log($"Adding item {item.UniqueID}");
_items.Add(item);
}
public void RemoveItem(ItemDataSo item)
{
if (_items.All(x => x.UniqueID != item.UniqueID)) return;
_items.Remove(item);
}
public void RemoveItem(string uniqueID)
{
var item = _items.FirstOrDefault(x => x.UniqueID == uniqueID);
if (item) _items.Remove(item);
}
public void ClearItems()
{
_items.Clear();
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 855a4eee12334e29938a4b6c564aa157
timeCreated: 1769701500

View File

@@ -0,0 +1,82 @@
using BriarQueen.Data.Identifiers;
using BriarQueen.Framework.Managers.Interaction.Data;
using NaughtyAttributes;
using UnityEngine;
namespace BriarQueen.Framework.Managers.Player.Data
{
[CreateAssetMenu(menuName = "Briar Queen/Items/New Item Data", fileName = "New Item Data")]
public class ItemDataSo : ScriptableObject
{
public enum ItemIdType
{
None = 0,
Pickup = 1,
Environment = 2,
PuzzleSlot = 3
}
[Header("Item Type")]
[SerializeField]
private ItemIdType _idType;
[Header("Item Details")]
[Tooltip("Pickup key used for inventory items.")]
[SerializeField]
[ShowIf(nameof(IsPickup))]
private ItemKey _itemKey;
[Tooltip("Environment key used for world interactables.")]
[SerializeField]
[ShowIf(nameof(IsEnvironment))]
private EnvironmentKey _environmentKey;
[Tooltip("Puzzle slot key used for puzzle slot interactables.")]
[SerializeField]
[ShowIf(nameof(IsPuzzleSlot))]
private PuzzleSlotKey _puzzleSlotKey;
[SerializeField]
private Sprite _icon;
[SerializeField]
private string _itemName;
[Header("Inventory Interaction (serialized inline, no extra SO assets)")]
[SerializeReference]
private BaseInteraction _interaction = new NoOpItemInteraction();
public Sprite Icon => _icon;
public string ItemName => _itemName;
public BaseInteraction Interaction => _interaction;
public bool IsPickup => _idType == ItemIdType.Pickup;
public bool IsEnvironment => _idType == ItemIdType.Environment;
public bool IsPuzzleSlot => _idType == ItemIdType.PuzzleSlot;
public ItemIdType IdType => _idType;
public ItemKey ItemKey => _itemKey;
public EnvironmentKey EnvironmentKey => _environmentKey;
public PuzzleSlotKey PuzzleSlotKey => _puzzleSlotKey;
public string UniqueID
{
get
{
return _idType switch
{
ItemIdType.Pickup when _itemKey != ItemKey.None =>
ItemIDs.Get(_itemKey),
ItemIdType.Environment when _environmentKey != EnvironmentKey.None =>
ItemIDs.Get(_environmentKey),
ItemIdType.PuzzleSlot when _puzzleSlotKey != PuzzleSlotKey.None =>
ItemIDs.Get(_puzzleSlotKey),
_ => string.Empty
};
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 668f0266fce84811b2efce4a48adff09
timeCreated: 1769700109

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: cfd9e5206a504db9a0aa92f0e934776f
timeCreated: 1773954968

View File

@@ -0,0 +1,201 @@
using System;
using System.Collections.Generic;
using System.Linq;
using BriarQueen.Data.Identifiers;
using BriarQueen.Framework.Coordinators.Events;
using BriarQueen.Framework.Events.Gameplay;
using BriarQueen.Framework.Events.UI;
using BriarQueen.Framework.Managers.UI;
using BriarQueen.Framework.Services.Tutorials;
using UnityEngine;
namespace BriarQueen.Framework.Managers.Player.Data.Tools
{
public class Toolbelt : IDisposable
{
private readonly Dictionary<ToolID, bool> _unlockedTools = new();
private readonly EventCoordinator _eventCoordinator;
private readonly TutorialService _tutorialService;
public IReadOnlyDictionary<ToolID, bool> UnlockedTools => _unlockedTools;
public ToolID CurrentTool { get; private set; } = ToolID.None;
public Toolbelt(EventCoordinator eventCoordinator, TutorialService tutorialService)
{
_eventCoordinator = eventCoordinator;
_tutorialService = tutorialService;
}
public void Initialize()
{
_eventCoordinator.Subscribe<OnNextToolChangedEvent>(OnNextToolChanged);
_eventCoordinator.Subscribe<OnPreviousToolChangedEvent>(OnPreviousToolChanged);
}
public void Dispose()
{
_eventCoordinator.Unsubscribe<OnNextToolChangedEvent>(OnNextToolChanged);
_eventCoordinator.Unsubscribe<OnPreviousToolChangedEvent>(OnPreviousToolChanged);
}
public void Unlock(ToolID id)
{
if (id == ToolID.None)
return;
_unlockedTools[id] = true;
Debug.Log($"{this} Tool {id} has been unlocked");
}
public void Lock(ToolID id)
{
if (id == ToolID.None)
return;
if (_unlockedTools.ContainsKey(id))
_unlockedTools[id] = false;
if (CurrentTool == id)
SelectNextTool();
}
public bool HasAccess(ToolID id)
{
if (id == ToolID.None)
return true;
return _unlockedTools.TryGetValue(id, out var unlocked) && unlocked;
}
public bool IsKnown(ToolID id)
{
if (id == ToolID.None)
return true;
return _unlockedTools.ContainsKey(id);
}
public void SelectTool(ToolID id)
{
if (!HasAccess(id))
return;
SetCurrentTool(id, 0);
}
public void SelectEmptyHands()
{
SetCurrentTool(ToolID.None, 0);
}
public void SelectNextTool()
{
Debug.Log($"SelectNextTool: {CurrentTool}");
CycleTool(1);
}
public void SelectPreviousTool()
{
CycleTool(-1);
}
private void OnNextToolChanged(OnNextToolChangedEvent e)
{
SelectNextTool();
}
private void OnPreviousToolChanged(OnPreviousToolChangedEvent e)
{
SelectPreviousTool();
}
private void CycleTool(int direction)
{
var selectableTools = GetSelectableTools();
Debug.Log($"CycleTool: {selectableTools.Count}");
foreach (var selectableTool in selectableTools)
{
Debug.Log($"CycleTool: {selectableTool}");
}
if (selectableTools.Count == 0)
{
SetCurrentTool(ToolID.None, direction);
return;
}
int currentIndex = selectableTools.IndexOf(CurrentTool);
if (currentIndex < 0)
{
int fallbackIndex = direction > 0 ? 0 : selectableTools.Count - 1;
SetCurrentTool(selectableTools[fallbackIndex], direction);
return;
}
int nextIndex = currentIndex + direction;
if (nextIndex < 0)
nextIndex = selectableTools.Count - 1;
else if (nextIndex >= selectableTools.Count)
nextIndex = 0;
Debug.Log($"CycleTool: Setting tool to {selectableTools[nextIndex]}");
SetCurrentTool(selectableTools[nextIndex], direction);
}
private void CheckToolsCycling()
{
_tutorialService.DisplayTutorial(TutorialPopupID.ToolCycling);
}
private void SetCurrentTool(ToolID newTool, int direction)
{
if (CurrentTool == newTool)
return;
CurrentTool = newTool;
CheckToolsCycling();
_eventCoordinator.Publish(new SelectedToolChangedEvent(CurrentTool, direction));
if (newTool == ToolID.None)
{
_eventCoordinator.Publish(
new OverrideCursorStyleChangeEvent(UICursorService.CursorStyle.Default));
return;
}
if (Enum.TryParse(newTool.ToString(), out UICursorService.CursorStyle style))
{
_eventCoordinator.Publish(new OverrideCursorStyleChangeEvent(style));
}
else
{
_eventCoordinator.Publish(
new OverrideCursorStyleChangeEvent(UICursorService.CursorStyle.Default));
}
}
private List<ToolID> GetSelectableTools()
{
var tools = _unlockedTools
.Where(x => x.Value && x.Key != ToolID.None)
.Select(x => x.Key)
.OrderBy(x => (int)x)
.ToList();
Debug.Log($"{_unlockedTools.Count} tools have been unlocked");
tools.Insert(0, ToolID.None);
Debug.Log($"[Get Selectable Tools: {tools.Count}]");
return tools;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 565885be67c447bbbb3a64e39ae1f734
timeCreated: 1773954972

View File

@@ -0,0 +1,449 @@
using System;
using System.Collections.Generic;
using System.Linq;
using BriarQueen.Data.Identifiers;
using BriarQueen.Data.IO.Saves;
using BriarQueen.Framework.Coordinators.Events;
using BriarQueen.Framework.Events.Gameplay;
using BriarQueen.Framework.Events.Save;
using BriarQueen.Framework.Events.UI;
using BriarQueen.Framework.Managers.Audio;
using BriarQueen.Framework.Managers.IO;
using BriarQueen.Framework.Managers.Player.Data;
using BriarQueen.Framework.Managers.Player.Data.Codex;
using BriarQueen.Framework.Managers.Player.Data.Inventory;
using BriarQueen.Framework.Managers.Player.Data.Tools;
using BriarQueen.Framework.Registries;
using BriarQueen.Framework.Services.Tutorials;
using UnityEngine;
using VContainer;
namespace BriarQueen.Framework.Managers.Player
{
/// <summary>
/// Owns player state (inventory + codex + tools) and keeps it in sync with save data.
/// </summary>
public class PlayerManager : IDisposable, IManager
{
private readonly CodexRegistry _codexRegistry;
private readonly EventCoordinator _eventCoordinator;
private readonly ItemRegistry _itemRegistry;
private readonly SaveManager _saveManager;
private readonly AudioManager _audioManager;
private readonly TutorialService _tutorialService;
private Backpack _backpack;
private Codex _codex;
private Toolbelt _toolbelt;
public bool Initialized { get; private set; }
[Inject]
public PlayerManager(
SaveManager saveManager,
ItemRegistry itemRegistry,
CodexRegistry codexRegistry,
EventCoordinator eventCoordinator,
TutorialService tutorialService,
AudioManager audioManager)
{
_saveManager = saveManager;
_itemRegistry = itemRegistry;
_codexRegistry = codexRegistry;
_eventCoordinator = eventCoordinator;
_tutorialService = tutorialService;
_audioManager = audioManager;
}
public void Initialize()
{
if (Initialized)
return;
_saveManager.OnSaveGameLoaded += LoadFromSave;
_saveManager.OnSaveRequested += UpdateSaveGame;
_backpack ??= new Backpack();
_codex ??= new Codex();
EnsureToolbelt();
if (_saveManager.IsGameLoaded && _saveManager.CurrentSave != null)
LoadFromSave(_saveManager.CurrentSave);
Initialized = true;
}
public void Dispose()
{
if (!Initialized)
return;
_saveManager.OnSaveGameLoaded -= LoadFromSave;
_saveManager.OnSaveRequested -= UpdateSaveGame;
_toolbelt?.Dispose();
_toolbelt = null;
Initialized = false;
}
public IReadOnlyList<ItemDataSo> GetInventoryItems()
{
return _backpack?.Items ?? Array.Empty<ItemDataSo>();
}
public IReadOnlyList<ItemDataSo> GetAllCollectedItems()
{
var collectedItems = new List<ItemDataSo>();
var saveCollected = _saveManager.CurrentSave?.CollectedItems;
if (saveCollected == null)
return collectedItems;
foreach (var item in saveCollected)
{
var template = _itemRegistry.FindItemTemplateByID(item.UniqueIdentifier);
if (template != null)
collectedItems.Add(template);
}
return collectedItems;
}
public IReadOnlyList<CodexEntrySo> GetDiscoveredCodexEntries()
{
return _codex?.Entries ?? Array.Empty<CodexEntrySo>();
}
public IEnumerable<CodexEntrySo> GetDiscoveredCodexEntriesByType(CodexType codexType)
{
return _codex?.GetEntriesByType(codexType) ?? Enumerable.Empty<CodexEntrySo>();
}
public bool HasCodexEntry(string uniqueIdentifier)
{
return _codex != null && _codex.HasEntry(uniqueIdentifier);
}
public bool HasCodexEntry(CodexEntrySo entry)
{
return _codex != null && _codex.HasEntry(entry);
}
public IReadOnlyDictionary<ToolID, bool> GetTools()
{
EnsureToolbelt();
return _toolbelt.UnlockedTools;
}
public bool HasAccessToTool(ToolID id)
{
return _toolbelt != null && _toolbelt.HasAccess(id);
}
#region Inventory / Selection
public void CollectItem(string uniqueIdentifier)
{
var item = _itemRegistry.FindItemTemplateByID(uniqueIdentifier);
if (item == null)
return;
CollectItem(item);
}
public void CollectItem(ItemDataSo item)
{
if (item == null)
return;
_backpack ??= new Backpack();
_backpack.AddItem(item);
var save = _saveManager.CurrentSave;
if (save != null)
{
save.CollectedItems ??= new List<ItemSaveData>();
if (save.CollectedItems.All(x => x.UniqueIdentifier != item.UniqueID))
{
save.CollectedItems.Add(new ItemSaveData
{
UniqueIdentifier = item.UniqueID
});
}
}
_audioManager.Play(AudioNameIdentifiers.Get(SFXKey.ItemCollected));
_eventCoordinator.PublishImmediate(new RequestGameSaveEvent());
_eventCoordinator.Publish(new InventoryChangedEvent());
}
public void RemoveItem(string uniqueIdentifier)
{
var item = _backpack?.Items.FirstOrDefault(x => x.UniqueID == uniqueIdentifier);
if (item == null)
return;
RemoveItem(item);
}
public void RemoveItem(ItemDataSo item)
{
if (item == null || _backpack == null)
return;
_backpack.RemoveItem(item);
_eventCoordinator.PublishImmediate(new RequestGameSaveEvent());
_eventCoordinator.Publish(new InventoryChangedEvent());
}
#endregion
#region Codex
public void UnlockCodexEntry(string uniqueIdentifier)
{
var entry = _codexRegistry.FindEntryByID(uniqueIdentifier);
if (entry == null)
return;
UnlockCodexEntry(entry);
}
public void UnlockCodexEntry(CodexEntrySo entry)
{
if (entry == null)
return;
_codex ??= new Codex();
if (_codex.HasEntry(entry))
return;
_codex.AddEntry(entry);
var save = _saveManager.CurrentSave;
if (save != null)
{
save.DiscoveredCodexEntries ??= new List<CodexSaveData>();
if (save.DiscoveredCodexEntries.All(x => x.UniqueIdentifier != entry.UniqueID))
{
save.DiscoveredCodexEntries.Add(new CodexSaveData
{
UniqueIdentifier = entry.UniqueID
});
}
}
_tutorialService.DisplayTutorial(TutorialPopupID.Codex);
_eventCoordinator.PublishImmediate(new RequestGameSaveEvent());
_eventCoordinator.Publish(new CodexChangedEvent(entry.EntryType));
}
#endregion
#region Tools
public ToolID GetEquippedTool()
{
EnsureToolbelt();
return _toolbelt.CurrentTool;
}
public void UnlockTool(ToolID id)
{
EnsureToolbelt();
if (_toolbelt.HasAccess(id))
return;
_toolbelt.Unlock(id);
Debug.Log($"Tool {id} has been unlocked");
var save = _saveManager.CurrentSave;
if (save != null)
{
save.Tools ??= new Dictionary<ToolID, bool>();
save.Tools[id] = true;
}
Debug.Log($"Tool {id} access after unlock = {_toolbelt.HasAccess(id)}");
_eventCoordinator.Publish(new ToolbeltChangedEvent(id, false));
_eventCoordinator.PublishImmediate(new RequestGameSaveEvent());
}
public void LockTool(ToolID id)
{
EnsureToolbelt();
if (!_toolbelt.UnlockedTools.TryGetValue(id, out var unlocked) || !unlocked)
return;
_toolbelt.Lock(id);
var save = _saveManager.CurrentSave;
if (save != null)
{
save.Tools ??= new Dictionary<ToolID, bool>();
save.Tools[id] = false;
}
_eventCoordinator.Publish(new ToolbeltChangedEvent(id, true));
_eventCoordinator.PublishImmediate(new RequestGameSaveEvent());
}
private void LoadToolsFromSave(SaveGame save)
{
_toolbelt?.Dispose();
_toolbelt = new Toolbelt(_eventCoordinator, _tutorialService);
_toolbelt.Initialize();
if (save.Tools == null)
return;
foreach (var kvp in save.Tools)
{
if (kvp.Value)
_toolbelt.Unlock(kvp.Key);
else
_toolbelt.Lock(kvp.Key);
}
}
private void EnsureToolbelt()
{
if (_toolbelt != null)
return;
_toolbelt = new Toolbelt(_eventCoordinator, _tutorialService);
_toolbelt.Initialize();
}
#endregion
#region Saving / Loading
private void LoadFromSave(SaveGame save)
{
if (save == null)
return;
LoadItemsFromSave(save);
LoadCodexFromSave(save);
LoadToolsFromSave(save);
}
private void LoadItemsFromSave(SaveGame save)
{
_backpack = new Backpack();
Debug.Log($"[PlayerManager] LoadItemsFromSave InventoryData count = {save.InventoryData?.Count ?? 0}");
if (save.InventoryData != null)
{
foreach (var item in save.InventoryData)
{
Debug.Log($"[PlayerManager] Loading InventoryData item '{item.UniqueIdentifier}'");
var itemData = _itemRegistry.FindItemTemplateByID(item.UniqueIdentifier);
if (!itemData)
{
Debug.LogWarning($"[PlayerManager] Missing item template '{item.UniqueIdentifier}', skipping.");
continue;
}
_backpack.AddItem(itemData);
}
}
Debug.Log($"[PlayerManager] Backpack count after load = {_backpack.Items?.Count ?? 0}");
_eventCoordinator.Publish(new SelectedItemChangedEvent(null));
_eventCoordinator.Publish(new InventoryChangedEvent());
}
private void LoadCodexFromSave(SaveGame save)
{
_codex = new Codex();
if (save.DiscoveredCodexEntries != null)
{
foreach (var codexEntry in save.DiscoveredCodexEntries)
{
var entryData = _codexRegistry.FindEntryByID(codexEntry.UniqueIdentifier);
if (!entryData)
{
Debug.LogWarning($"[PlayerManager] Missing codex entry '{codexEntry.UniqueIdentifier}', skipping.");
continue;
}
_codex.AddEntry(entryData);
}
}
}
private void UpdateSaveGame(SaveGame save)
{
if (save == null)
return;
Debug.Log($"[PlayerManager] UpdateSaveGame backpack count = {_backpack?.Items?.Count ?? 0}");
save.InventoryData = new List<ItemSaveData>();
if (_backpack?.Items != null)
{
foreach (var item in _backpack.Items)
{
if (!item)
continue;
Debug.Log($"[PlayerManager] Writing InventoryData item '{item.UniqueID}'");
save.InventoryData.Add(new ItemSaveData
{
UniqueIdentifier = item.UniqueID
});
}
}
Debug.Log($"[PlayerManager] Final InventoryData count = {save.InventoryData.Count}");
save.DiscoveredCodexEntries ??= new List<CodexSaveData>();
save.DiscoveredCodexEntries.Clear();
if (_codex?.Entries != null)
{
foreach (var entry in _codex.Entries)
{
if (!entry)
continue;
save.DiscoveredCodexEntries.Add(new CodexSaveData
{
UniqueIdentifier = entry.UniqueID
});
}
}
save.Tools ??= new Dictionary<ToolID, bool>();
save.Tools.Clear();
if (_toolbelt?.UnlockedTools != null)
{
foreach (var kvp in _toolbelt.UnlockedTools)
{
save.Tools[kvp.Key] = kvp.Value;
}
}
}
#endregion
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 3ec45c587ec54a4eb6ffbdedf10eed57
timeCreated: 1769703840

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 46d5ba8eaeca4785ae5052c314f42104
timeCreated: 1769706518

Binary file not shown.

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: cdeb86849b3447b08f904cbe2af60572
timeCreated: 1773830601

View File

@@ -0,0 +1,15 @@
using Cysharp.Threading.Tasks;
using UnityEngine.UI;
namespace BriarQueen.Framework.Managers.UI.Base
{
public interface IHud
{
UniTask Hide();
UniTask Show();
UniTask DisplayInteractText(string text);
GraphicRaycaster Raycaster { get; }
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 69ce1a09c8ba468184443377cb160251
timeCreated: 1773830812

View File

@@ -0,0 +1,15 @@
using Cysharp.Threading.Tasks;
using UnityEngine;
namespace BriarQueen.Framework.Managers.UI.Base
{
public interface IPopup
{
UniTask Show();
UniTask Hide();
UniTask Play(string text, float duration);
GameObject GameObject { get; }
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: b6c40bdc6e55460e859a7d0d76422e5b
timeCreated: 1773830634

View File

@@ -0,0 +1,12 @@
using BriarQueen.Framework.Events.UI;
using Cysharp.Threading.Tasks;
using UnityEngine;
namespace BriarQueen.Framework.Managers.UI.Base
{
public interface IScreenFader
{
UniTask FadeFromAsync(float duration);
UniTask FadeToAsync(float duration);
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 486cc0093cdf41db9db9d4a4af124a6f
timeCreated: 1773831975

View File

@@ -0,0 +1,12 @@
using Cysharp.Threading.Tasks;
namespace BriarQueen.Framework.Managers.UI.Base
{
public interface IUIWindow
{
UniTask Show();
UniTask Hide();
WindowType WindowType { get; }
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 27aca68a2b694025b3ed9c26b2d7d5b2
timeCreated: 1769707713

View File

@@ -0,0 +1,9 @@
namespace BriarQueen.Framework.Managers.UI.Base
{
public enum WindowType
{
PauseMenuWindow,
SettingsWindow,
CodexWindow
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 616cfa2de3124c308c9706f50b7ab339
timeCreated: 1773832262

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 75c1e9dba297a499998f45e608b4b71a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

View File

@@ -0,0 +1,6 @@
using BriarQueen.Framework.Events.System;
namespace BriarQueen.Framework.Managers.UI.Events
{
public record UIToggleSettingsWindow(bool Show) : IEvent;
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cf239878f7aa941c2abbef0ebb2e41bd

View File

@@ -0,0 +1,285 @@
using System;
using System.Collections.Generic;
using BriarQueen.Framework.Coordinators.Events;
using BriarQueen.Framework.Events.UI;
using UnityEngine;
using UnityEngine.UI;
using VContainer;
namespace BriarQueen.Framework.Managers.UI
{
public sealed class UICursorService : MonoBehaviour
{
public enum CursorStyle
{
Default,
Interact,
Talk,
Pickup,
Inspect,
UseItem,
Travel,
Knife,
}
[Header("System Cursor")]
[SerializeField]
private bool _enableCustomCursor = true;
[SerializeField]
private bool _restoreDefaultCursorOnDisable = true;
[Header("Software Cursor")]
[SerializeField]
private RectTransform _virtualCursorTransform;
[SerializeField]
private Image _virtualCursorImage;
[Header("Styles")]
[SerializeField]
private CursorStyle _startingStyle = CursorStyle.Default;
[SerializeField]
private List<CursorStyleEntry> _styles = new();
private readonly Dictionary<CursorStyle, CursorStyleEntry> _styleMap = new();
private EventCoordinator _eventCoordinator;
private CursorStyle _currentStyle;
private CursorStyle _currentStyleOverride = CursorStyle.Default;
private bool _isStyleOverridden;
private Texture2D _currentTexture;
private Vector2 _currentHotspot;
private CursorMode _currentMode;
private bool _useVirtualCursor;
public CursorStyleEntry CurrentStyleEntry => _styleMap[_currentStyle];
[Inject]
private void Construct(EventCoordinator eventCoordinator)
{
_eventCoordinator = eventCoordinator;
}
private void Awake()
{
BuildStyleMap();
_currentStyle = _startingStyle;
_currentStyleOverride = CursorStyle.Default;
_isStyleOverridden = false;
}
private void OnEnable()
{
_eventCoordinator?.Subscribe<CursorStyleChangeEvent>(OnCursorStyleChangeEvent);
_eventCoordinator?.Subscribe<OverrideCursorStyleChangeEvent>(OnOverrideCursorStyleChangeEvent);
ApplyCurrentEffectiveStyle();
ApplyCursorModeVisuals();
}
private void OnDisable()
{
_eventCoordinator?.Unsubscribe<CursorStyleChangeEvent>(OnCursorStyleChangeEvent);
_eventCoordinator?.Unsubscribe<OverrideCursorStyleChangeEvent>(OnOverrideCursorStyleChangeEvent);
if (_restoreDefaultCursorOnDisable)
Cursor.SetCursor(null, Vector2.zero, CursorMode.Auto);
}
private void OnValidate()
{
BuildStyleMap();
}
public void SetUseVirtualCursor(bool useVirtualCursor)
{
if (_useVirtualCursor == useVirtualCursor)
return;
_useVirtualCursor = useVirtualCursor;
ApplyCursorModeVisuals();
ApplyCurrentEffectiveStyle();
}
public void SetVirtualCursorPosition(Vector2 screenPosition)
{
if (!_useVirtualCursor || _virtualCursorTransform == null)
return;
_virtualCursorTransform.position = screenPosition;
}
public void SetStyle(CursorStyle style)
{
_currentStyle = style;
ApplyCurrentEffectiveStyle();
}
public void SetOverrideStyle(CursorStyle style)
{
if (style == CursorStyle.Default)
{
ClearOverrideStyle();
return;
}
_isStyleOverridden = true;
_currentStyleOverride = style;
ApplyCurrentEffectiveStyle();
}
public void ClearOverrideStyle()
{
_isStyleOverridden = false;
_currentStyleOverride = CursorStyle.Default;
ApplyCurrentEffectiveStyle();
}
public void ResetToDefault()
{
_currentStyle = CursorStyle.Default;
ClearOverrideStyle();
}
private void OnCursorStyleChangeEvent(CursorStyleChangeEvent evt)
{
_currentStyle = evt.Style;
ApplyCurrentEffectiveStyle();
}
private void OnOverrideCursorStyleChangeEvent(OverrideCursorStyleChangeEvent evt)
{
if (evt.Style == CursorStyle.Default)
{
ClearOverrideStyle();
return;
}
_isStyleOverridden = true;
_currentStyleOverride = evt.Style;
ApplyCurrentEffectiveStyle();
}
private void ApplyCursorModeVisuals()
{
Cursor.visible = !_useVirtualCursor;
if (_virtualCursorImage != null)
_virtualCursorImage.enabled = _useVirtualCursor;
}
private void ApplyCurrentEffectiveStyle()
{
var style = GetEffectiveStyle();
if (_useVirtualCursor)
{
ApplyVirtualCursorStyle(style);
}
else
{
ApplySystemCursorStyle(style);
}
}
private CursorStyle GetEffectiveStyle()
{
return _isStyleOverridden ? _currentStyleOverride : _currentStyle;
}
private void ApplyVirtualCursorStyle(CursorStyle style)
{
if (_virtualCursorImage == null)
return;
if (!_styleMap.TryGetValue(style, out var entry) || entry.Texture == null)
{
if (!_styleMap.TryGetValue(CursorStyle.Default, out entry) || entry.Texture == null)
return;
}
var rect = new Rect(0, 0, entry.Texture.width, entry.Texture.height);
var pivot = new Vector2(0.5f, 0.5f);
_virtualCursorImage.sprite = Sprite.Create(entry.Texture, rect, pivot);
}
private void ApplySystemCursorStyle(CursorStyle style)
{
if (!_enableCustomCursor)
return;
if (!_styleMap.TryGetValue(style, out var entry) || entry.Texture == null)
{
if (_styleMap.TryGetValue(CursorStyle.Default, out var defaultEntry) && defaultEntry.Texture != null)
{
entry = defaultEntry;
}
else
{
Cursor.SetCursor(null, Vector2.zero, CursorMode.Auto);
CacheApplied(null, Vector2.zero, CursorMode.Auto);
return;
}
}
var hotspot = entry.Hotspot;
if (entry.Texture != null && entry.HotspotIsNormalized)
{
hotspot = new Vector2(
hotspot.x * entry.Texture.width,
hotspot.y * entry.Texture.height);
}
if (IsSameAsCurrent(entry.Texture, hotspot, entry.Mode))
return;
Cursor.SetCursor(entry.Texture, hotspot, entry.Mode);
CacheApplied(entry.Texture, hotspot, entry.Mode);
}
private bool IsSameAsCurrent(Texture2D texture, Vector2 hotspot, CursorMode mode)
{
return _currentTexture == texture &&
_currentHotspot == hotspot &&
_currentMode == mode;
}
private void CacheApplied(Texture2D texture, Vector2 hotspot, CursorMode mode)
{
_currentTexture = texture;
_currentHotspot = hotspot;
_currentMode = mode;
}
private void BuildStyleMap()
{
_styleMap.Clear();
if (_styles == null)
return;
for (var i = 0; i < _styles.Count; i++)
{
var entry = _styles[i];
_styleMap[entry.Style] = entry;
}
}
[Serializable]
public struct CursorStyleEntry
{
public CursorStyle Style;
public Texture2D Texture;
public Vector2 Hotspot;
public CursorMode Mode;
public bool HotspotIsNormalized;
public Vector2 TooltipOffset;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 515a74faf67c4af9912b8b8eed4d8ad7
timeCreated: 1769716152

View File

@@ -0,0 +1,434 @@
using System;
using System.Collections.Generic;
using BriarQueen.Data.Identifiers;
using BriarQueen.Framework.Coordinators.Events;
using BriarQueen.Framework.Events.UI;
using BriarQueen.Framework.Managers.Interaction;
using BriarQueen.Framework.Managers.IO;
using BriarQueen.Framework.Managers.UI.Base;
using BriarQueen.Framework.Managers.UI.Events;
using BriarQueen.Framework.Services.Settings;
using Cysharp.Threading.Tasks;
using UnityEngine;
using VContainer;
namespace BriarQueen.Framework.Managers.UI
{
/// <summary>
/// UIManager:
/// - Modal windows use the window stack
/// - Non-modal UI (popups / fader) does not use the stack
/// - HUD visibility is event-driven
/// - Concrete UI implementations are hidden behind interfaces
/// </summary>
public class UIManager : IDisposable, IManager
{
private readonly EventCoordinator _eventCoordinator;
private readonly InteractManager _interactManager;
private readonly SaveManager _saveManager;
private readonly SettingsService _settingsService;
private readonly Dictionary<WindowType, IUIWindow> _windows = new();
private readonly Stack<IUIWindow> _windowStack = new();
private bool _disposed;
public bool Initialized { get; private set; }
private IHud _hudContainer;
private IPopup _infoPopup;
private IPopup _tutorialPopup;
private IScreenFader _screenFader;
[Inject]
public UIManager(
EventCoordinator eventCoordinator,
InteractManager interactManager,
SettingsService settingsService,
SaveManager saveManager)
{
_eventCoordinator = eventCoordinator;
_interactManager = interactManager;
_settingsService = settingsService;
_saveManager = saveManager;
}
private IUIWindow ActiveWindow => _windowStack.Count > 0 ? _windowStack.Peek() : null;
public bool IsAnyUIOpen => _windowStack.Count > 0;
public void Initialize()
{
if (Initialized)
return;
_disposed = false;
SubscribeToEvents();
Initialized = true;
}
public void Dispose()
{
if (!Initialized || _disposed)
return;
_disposed = true;
UnsubscribeFromEvents();
ResetUIStateHard();
Initialized = false;
}
private void SubscribeToEvents()
{
_eventCoordinator.Subscribe<PauseButtonClickedEvent>(OnPauseClickReceived);
_eventCoordinator.Subscribe<ToggleCodexEvent>(ToggleCodexWindow);
_eventCoordinator.Subscribe<UIToggleSettingsWindow>(ToggleSettingsWindow);
_eventCoordinator.Subscribe<FadeEvent>(OnFadeEvent);
_eventCoordinator.Subscribe<DisplayInteractEvent>(OnDisplayInteractText);
_eventCoordinator.Subscribe<DisplayTutorialPopupEvent>(OnTutorialDisplayPopup);
_eventCoordinator.Subscribe<CodexChangedEvent>(OnCodexChangedEvent);
_eventCoordinator.Subscribe<UIToggleHudEvent>(OnHudToggleEvent);
_eventCoordinator.Subscribe<ToolbeltChangedEvent>(OnToolbeltChangedEvent);
}
private void UnsubscribeFromEvents()
{
_eventCoordinator.Unsubscribe<PauseButtonClickedEvent>(OnPauseClickReceived);
_eventCoordinator.Unsubscribe<ToggleCodexEvent>(ToggleCodexWindow);
_eventCoordinator.Unsubscribe<UIToggleSettingsWindow>(ToggleSettingsWindow);
_eventCoordinator.Unsubscribe<FadeEvent>(OnFadeEvent);
_eventCoordinator.Unsubscribe<DisplayInteractEvent>(OnDisplayInteractText);
_eventCoordinator.Unsubscribe<DisplayTutorialPopupEvent>(OnTutorialDisplayPopup);
_eventCoordinator.Unsubscribe<CodexChangedEvent>(OnCodexChangedEvent);
_eventCoordinator.Unsubscribe<UIToggleHudEvent>(OnHudToggleEvent);
_eventCoordinator.Unsubscribe<ToolbeltChangedEvent>(OnToolbeltChangedEvent);
}
public void RegisterWindow(IUIWindow window)
{
if (window == null)
return;
_windows[window.WindowType] = window;
window.Hide().Forget();
}
public void RegisterHUD(IHud hudContainer)
{
_hudContainer = hudContainer;
if (_hudContainer != null)
{
_hudContainer.Hide().Forget();
_interactManager.AddUIRaycaster(_hudContainer.Raycaster);
}
}
public void RegisterInfoPopup(IPopup infoPopup)
{
_infoPopup = infoPopup;
if (_infoPopup != null)
_infoPopup.Hide().Forget();
}
public void RegisterTutorialPopup(IPopup tutorialPopup)
{
_tutorialPopup = tutorialPopup;
if (_tutorialPopup != null)
_tutorialPopup.Hide().Forget();
}
public void RegisterScreenFader(IScreenFader screenFader)
{
_screenFader = screenFader;
}
private IUIWindow GetWindow(WindowType windowType)
{
return _windows.TryGetValue(windowType, out var window) ? window : null;
}
private async UniTask ApplyHudVisibility(bool visible)
{
if (_disposed || _hudContainer == null)
return;
try
{
if (visible)
await _hudContainer.Show();
else
await _hudContainer.Hide();
}
catch (Exception ex)
{
Debug.LogError($"[UIManager] ApplyHudVisibility error: {ex}");
}
}
private void OnPauseClickReceived(PauseButtonClickedEvent _)
{
if (_windowStack.Count > 0)
{
CloseTopWindow();
return;
}
OpenWindow(WindowType.PauseMenuWindow);
}
private void ToggleSettingsWindow(UIToggleSettingsWindow eventData)
{
if (eventData.Show)
OpenWindow(WindowType.SettingsWindow);
else
CloseWindow(WindowType.SettingsWindow);
}
private void ToggleCodexWindow(ToggleCodexEvent eventData)
{
if (eventData.Shown)
OpenWindow(WindowType.CodexWindow);
else
CloseWindow(WindowType.CodexWindow);
}
private void OnCodexChangedEvent(CodexChangedEvent eventData)
{
if (_infoPopup == null)
return;
var duration = _settingsService?.Game?.PopupDisplayDuration ?? 3f;
var codexText = GetCodexTextForEntry(eventData.EntryType);
_infoPopup.Play(codexText, duration).Forget();
}
private void OnToolbeltChangedEvent(ToolbeltChangedEvent eventData)
{
if (_infoPopup == null)
return;
var duration = _settingsService?.Game?.PopupDisplayDuration ?? 3f;
var toolText = GetToolbeltTextForEntry(eventData.ToolID, eventData.Lost);
_infoPopup.Play(toolText, duration).Forget();
}
private string GetToolbeltTextForEntry(ToolID toolID, bool lost)
{
if (lost)
return $"You lost the {toolID.ToString()}.";
else
return $"You gained the {toolID.ToString()}.";
}
private string GetCodexTextForEntry(CodexType codexType)
{
return codexType switch
{
CodexType.BookEntry => "You've acquired a new book entry.",
CodexType.PuzzleClue => "You've acquired a new puzzle clue.",
CodexType.Photo => "You've acquired a new photo.",
_ => string.Empty
};
}
private void OnTutorialDisplayPopup(DisplayTutorialPopupEvent eventData)
{
if (_tutorialPopup == null)
return;
if (!_settingsService.AreTutorialsEnabled())
return;
var duration = 3f;
var tutorialText = TutorialPopupTexts.AllPopups[eventData.TutorialID];
_tutorialPopup.Play(tutorialText, duration).Forget();
}
private void OnDisplayInteractText(DisplayInteractEvent eventData)
{
if (_hudContainer == null)
return;
_hudContainer.DisplayInteractText(eventData.Message).Forget();
}
private void OnFadeEvent(FadeEvent eventData)
{
if (_screenFader == null)
return;
if (eventData.Hidden)
_screenFader.FadeFromAsync(eventData.Duration).Forget();
else
_screenFader.FadeToAsync(eventData.Duration).Forget();
}
private void OnHudToggleEvent(UIToggleHudEvent eventData)
{
ApplyHudVisibility(eventData.Show).Forget();
}
public void OpenWindow(WindowType windowType)
{
OpenWindowInternal(windowType).Forget();
}
private async UniTask OpenWindowInternal(WindowType windowType)
{
if (_disposed)
return;
var window = GetWindow(windowType);
if (window == null)
{
Debug.LogError($"[UIManager] Window of type {windowType} not registered.");
return;
}
if (ActiveWindow == window)
return;
if (ActiveWindow != null)
await ActiveWindow.Hide();
_windowStack.Push(window);
await window.Show();
NotifyUIStackChanged();
}
public void CloseWindow(WindowType windowType)
{
CloseWindowInternal(windowType).Forget();
}
private async UniTask CloseWindowInternal(WindowType windowType)
{
if (_disposed || _windowStack.Count == 0)
return;
var target = GetWindow(windowType);
if (target == null)
return;
while (_windowStack.Count > 0)
{
var current = _windowStack.Pop();
if (current != null)
await current.Hide();
if (current == target)
break;
}
if (ActiveWindow != null)
await ActiveWindow.Show();
NotifyUIStackChanged();
}
public void CloseTopWindow()
{
CloseTopWindowInternal().Forget();
}
private async UniTask CloseTopWindowInternal()
{
if (_disposed || _windowStack.Count == 0)
return;
var top = _windowStack.Pop();
if (top != null)
await top.Hide();
if (ActiveWindow != null)
await ActiveWindow.Show();
NotifyUIStackChanged();
}
public void ResetUIState()
{
ResetUIStateAsync().Forget();
}
public async UniTask ResetUIStateAsync()
{
while (_windowStack.Count > 0)
{
var window = _windowStack.Pop();
if (window != null)
{
try
{
await window.Hide();
}
catch
{
}
}
}
if (_tutorialPopup != null)
{
try
{
await _tutorialPopup.Hide();
}
catch
{
}
}
if (_infoPopup != null)
{
try
{
await _infoPopup.Hide();
}
catch
{
}
}
NotifyUIStackChanged();
}
private void ResetUIStateHard()
{
foreach (var kv in _windows)
{
if (kv.Value is Component component && component != null)
component.gameObject.SetActive(false);
}
if (_tutorialPopup?.GameObject != null)
_tutorialPopup.GameObject.SetActive(false);
if (_infoPopup?.GameObject != null)
_infoPopup.GameObject.SetActive(false);
if (_hudContainer is Component hudComponent && hudComponent != null)
hudComponent.gameObject.SetActive(false);
if (_screenFader is Component faderComponent && faderComponent != null)
faderComponent.gameObject.SetActive(false);
_windowStack.Clear();
}
private void NotifyUIStackChanged()
{
_eventCoordinator.Publish(new UIStackChangedEvent(_windowStack.Count > 0));
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 877f1720bf604f91a1b277e25a03dcbe
timeCreated: 1769706518