Danro/Assets/Scripts/Managers/GameManager.cs
Danil Iarosh 1cecbf2c11
Some checks failed
Gitea Actions Demo / Explore-Gitea-Actions (push) Has been cancelled
fix: avoid logger dispose errors in free-play flow
- Исправлен баг `GameManager.NewGame()`: в режиме бесплатной игры (`payment.freeGame = true`) больше не выполняется проверка/списание баланса.
- Добавлен явный guard для free-play перед `Balance.CanStartGame()` и `Balance.SpendBalance(...)`, чтобы не было нежелательных списаний.
- Устранена ошибка закрытия лог-файла `Cannot access a disposed object`:
  - `FileLogger` переведен на безопасный паттерн `Dispose(bool)` с идемпотентностью;
  - добавлена защита от повторного закрытия/записи в уже закрытый поток;
  - финализатор `FileLogger` больше не выполняет managed-cleanup.
- Устранен риск повторной очистки в `CompositeLogger`:
  - добавлен флаг `_disposed`;
  - `Dispose(bool)` теперь выполняется только один раз;
  - финализатор вызывает `Dispose(false)` вместо полного `Dispose()`.
2026-02-24 14:02:38 +03:00

679 lines
26 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.IO;
using RR.Common.Controls;
using UnityEngine;
using UnityEngine.Audio;
using UnityEngine.SceneManagement;
using RR.Common.Managers;
using RR.Common.Settings;
using RR.Common.Utility;
using RR.Game2D.Behaviour;
using RR.Game2D.Entities;
namespace RR.Game2D.Core.Managers
{
[DefaultExecutionOrder(-1000)]
public class GameManager : MonoBehaviour
{
// Singleton instance
public static GameManager Instance { get; private set; }
// Logger
public static CompositeLogger Logger { get; private set; }
// Game settings (новая система)
public SettingsManager Settings { get; private set; }
// Audio control for game
public AudioManager Audio {get; private set;}
// Payment control for game
public PaymentManager Payment {get; private set;}
// Balance control for game
public BalanceManager Balance {get; private set;}
// Inactivity control for game
public InactivityManager Inactivity {get; private set;}
// Plate control for game
public ComSystemManager Plate { get; private set; }
// Light control for game
public LightController Light { get; private set; }
// Globals
[Header("Globals")]
[SerializeField] private string globalsPrefabResourcePath = "Prefabs/Global/Globals";
[SerializeField] private string AudioMixerResourcePath = "Audio/Master";
private GameObject globals;
[Header("Пауза")]
public bool IsPaused { get; private set; }
[Header("Базовые настройки игры")]
public int StartingLives { get; private set; } = 3;
public int MaxLives { get; private set; } = 3;
public int LivesIncrement { get; private set; } = 0;
[Header("Информация для игры")]
public int World { get; private set; } = 1;
public int Stage { get; private set; } = 1;
private int StagesByWorld { get; set; } = 3;
public int Lives { get; set; } = 3;
public int Coins { get; private set; } = 0;
private float _gameStartTime;
[Header("Игрок")]
[SerializeField] private GameObject playerPrefab;
[SerializeField] private string playerPrefabResourcePath = "Prefabs/Player_New"; // Путь в Resources для загрузки префаба игрока, если не назначен в инспекторе
public GameObject PlayerObj { get; private set; }
private bool _shouldSpawnPlayer;
private void OnEnable() => SceneManager.sceneLoaded += OnSceneLoaded;
private void OnDisable() => SceneManager.sceneLoaded -= OnSceneLoaded;
// События менеджера
public event Action<bool> PauseStateChanged; // Когда меняется состояние паузы
public event Action<GameObject> PlayerSpawned; // Когда игрос спавнится на сцене
public event Action<int> CoinsChanged; // Когда меняется кол-во монет
public event Action<int> MaxLivesChanged; // Когда меняется максимальное кол-во жизней
public event Action<int> LivesChanged; // Когда меняется кол-во жизней
public event Action<float> GameOverEvent; // Когда игрок проигрывает
public event Action<float> GameWonEvent; // Когда игрок выигрывает
#region Unity Lifetime
// Singleton
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void Initialize()
{
if (Instance != null) return;
GameObject go = new(nameof(GameManager));
Instance = go.AddComponent<GameManager>();
go.AddComponent<AutoCursorHider>();
DontDestroyOnLoad(go);
}
private void Awake()
{
// ---- Singleton ----
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
// -------------------
// Initialize Logger
if (Logger == null)
{
Logger = new CompositeLogger();
FileLogger fileLogger = new(Path.Combine(Directory.GetParent(Application.dataPath)?.ToString() ?? Application.dataPath, "game.log"));
Logger.Initialize(fileLogger);
}
// Initialize Audio control
AudioMixer audioMixer = Resources.Load<AudioMixer>(AudioMixerResourcePath);
if (Audio == null)
{
Audio = gameObject.AddComponent<AudioManager>();
Audio.Initialize(audioMixer);
}
// Initialize Global Scripts from prefab
if (globals == null)
{
GameObject prefab = Resources.Load<GameObject>(globalsPrefabResourcePath);
if (prefab != null)
{
Debug.Log($"[GameManager] Loading Globals prefab from {globalsPrefabResourcePath}");
globals = Instantiate(prefab);
DontDestroyOnLoad(globals);
Debug.Log($"[GameManager] Globals instantiated. SettingsManager.Instance = {(SettingsManager.Instance != null ? "exists" : "null")}");
}
else
{
Debug.LogError($"[GameManager] Failed to load Globals prefab from {globalsPrefabResourcePath}");
}
}
// Initialize Settings Manager (новая система) from Globals
if (Settings == null) Settings = globals.GetComponent<SettingsManager>();
if (Settings == null) Settings = SettingsManager.Instance;
if (Settings != null)
{
Settings.OnSettingsLoaded += OnSettingsChanged;
Settings.OnSettingsSaved += OnSettingsChanged;
}
// Initialize Payment control
if (Payment == null) Payment = globals.GetComponent<PaymentManager>();
// Initialize Balance control
if (Balance == null) Balance = globals.GetComponent<BalanceManager>();
if (Balance == null) Balance = BalanceManager.Instance;
// Initialize Inactivity control
if (Inactivity == null) Inactivity = gameObject.AddComponent<InactivityManager>();
if (Inactivity == null) Inactivity = InactivityManager.Instance;
if (Plate == null) Plate = globals.GetComponent<ComSystemManager>();
if (Plate != null) Light = Plate.Light;
if (Light) Light.SetLight(LightMode.DiagonalWave)
.Then(LightMode.DiagonalWave2)
.Then(LightMode.Rainbow)
.Then(LightMode.FallingRows)
.Then(LightMode.SeaWave)
.Loop();
Payment.OnCreditAdded.AddListener(OnCreditAdded);
_gameStartTime = Time.unscaledTime;
MaxLives = StartingLives;
MaxLivesChanged?.Invoke(MaxLives);
if (SceneManager.GetActiveScene().name != "MainMenu") _shouldSpawnPlayer = true;
}
private void OnDestroy()
{
if (!Light) return;
Light.StopBlink(false);
Light.StopSequence();
if (Instance == this) Instance = null;
}
private void OnApplicationQuit()
{
OnDestroy();
}
/// <summary>
/// Поиск префаба игрока.
/// Если не найдет префаб из инспектора, ищет в "Resources"
/// </summary>
/// <returns> Префаб игрока </returns>
private GameObject ResolvePlayerPrefab()
{
if (playerPrefab != null) return playerPrefab;
GameObject loaded = Resources.Load<GameObject>(playerPrefabResourcePath);
if (loaded == null)
{
Logger.LogError(
gameObject.name,
$"playerPrefab is null. Resources.Load('{playerPrefabResourcePath}') вернул null." +
$" Ожидается файл: Assets/Resources/{playerPrefabResourcePath}.prefab (точное совпадение регистра)."
);
}
return loaded;
}
#endregion
#region Payment Event Handlers
private void OnCreditAdded(int newCount)
{
if (SceneManager.GetActiveScene().name == "MainMenu")
{
if (newCount > 1)
{
MaxLives += LivesIncrement;
}
}
// Debug.Log($"Max Lives: {MaxLives}");
MaxLivesChanged?.Invoke(MaxLives);
}
#endregion
#region Settings Event Handlers
private void OnSettingsChanged()
{
if (Settings == null) return;
// Получаем настройки из нового SettingsManager
StartingLives = Settings.GetInt("gameplay.startLives");
LivesIncrement = Settings.GetInt("gameplay.additionalLives");
int currentCredits = Payment?.CreditsCount ?? 0;
MaxLives = currentCredits > 1 ? StartingLives + (currentCredits - 1) * LivesIncrement : StartingLives;
MaxLivesChanged?.Invoke(MaxLives);
}
#endregion
#region Pause
/// <summary>
/// Метод паузы игры.
/// Ставит на паузу все SFX и саму игру
/// </summary>
/// <param name="pause">Ставить ли на паузу</param>
public void SetPause(bool pause)
{
if (IsPaused == pause) return;
IsPaused = pause;
Time.timeScale = pause ? 0f : 1f;
Audio.SmoothPauseMusic(pause);
PauseStateChanged?.Invoke(IsPaused);
}
// Можно заменить на другое поведение
/// <summary>
/// Метод переключателя паузы.
/// </summary>
public void TogglePause() => SetPause(!IsPaused);
// --
#endregion
#region Levels and Scene management
/// <summary>
/// Начало новой игры.
/// Загружает первый уровень.
/// Устанавливает жизни на 3, монеты на 0
/// </summary>
public void NewGame()
{
Debug.Log("[GameManager] NewGame called");
bool isFreeGame = Settings != null && Settings.GetBool("payment.freeGame");
// Проверка баланса перед началом игры
if (!isFreeGame && Balance != null && !Balance.CanStartGame())
{
Logger?.Log(gameObject.name, "Cannot start game - insufficient balance");
Debug.LogWarning("[GameManager] Cannot start - insufficient balance");
return;
}
int livesToGive = StartingLives;
int balanceToSpend = 0;
if (!isFreeGame && Balance != null)
{
int currentBalance = Balance.CurrentBalance;
int impulseCost = Settings?.GetInt("payment.paymentImpulseCost") ?? 10;
// Рассчитываем количество оплат
int totalImpulses = currentBalance / impulseCost;
// Первая оплата даёт базовые жизни
// Каждая дополнительная оплата даёт LivesIncrement жизней
if (totalImpulses > 0)
{
int additionalImpulses = totalImpulses - 1;
int bonusLives = additionalImpulses * LivesIncrement;
livesToGive = StartingLives + bonusLives;
Debug.Log($"[GameManager] Balance: {currentBalance}, Impulses: {totalImpulses}, Base lives: {StartingLives}, Bonus lives: {bonusLives}, Total lives: {livesToGive}");
}
// Списываем ВЕСЬ баланс
balanceToSpend = currentBalance;
if (!Balance.SpendBalance(balanceToSpend, "начало игры (полный баланс)"))
{
Logger?.Log(gameObject.name, "Failed to spend balance for game start");
Debug.LogError("[GameManager] Failed to spend balance!");
return;
}
Debug.Log($"[GameManager] Spent entire balance: {balanceToSpend}, Lives given: {livesToGive}");
}
else if (isFreeGame)
{
Debug.Log("[GameManager] Free game mode enabled. Balance spend skipped.");
}
Lives = livesToGive;
LivesChanged?.Invoke(Lives);
Coins = 0;
CoinsChanged?.Invoke(Coins);
_gameStartTime = Time.time;
Logger.Log(gameObject.name,$"Game start time = {_gameStartTime}");
// Активируем шапку и эффекты при начале игры
if (Light)
{
Light.StopBlink();
if (Light.IsSequencePlaying) Light.StopSequence();
Light.SetLight(LightMode.Rainbow).Loop();
}
// Включаем отслеживание бездействия
if (Inactivity != null) Inactivity.EnableInactivityCheck();
LoadLevel(1, 1);
}
public void ContinueGame()
{
// Проверка баланса перед продолжением игры
if (Balance != null && !Balance.CanContinueGame())
{
Logger?.Log(gameObject.name, "Cannot continue game - insufficient balance");
return;
}
// Списание баланса за продолжение игры
if (Balance != null && !Balance.SpendForContinue())
{
Logger?.Log(gameObject.name, "Failed to spend balance for continue");
return;
}
Lives = StartingLives + 1;
LivesChanged?.Invoke(Lives);
Coins = 0;
CoinsChanged?.Invoke(Coins);
// Сбрасываем таймер бездействия
if (Inactivity != null) Inactivity.ResetInactivityTimer();
RespawnPlayer();
}
// Можно заменить на другое поведение
/// <summary>
/// Окончание игры.
/// Загружает сцену главного меню
/// </summary>
private void GameOver()
{
float totalTime = Time.unscaledTime - _gameStartTime;
// Отключаем отслеживание бездействия
if (Inactivity != null) Inactivity.DisableInactivityCheck();
Light?.SetLight(LightMode.BlueWave, 30);
GameOverEvent?.Invoke(totalTime);
}
// --
private void GameWin()
{
float totalTime = Time.unscaledTime - _gameStartTime;
// Отключаем отслеживание бездействия
if (Inactivity != null) Inactivity.DisableInactivityCheck();
Light?.SetLight(LightMode.FinalWave, 30);
GameWonEvent?.Invoke(totalTime);
}
/// <summary>
/// Метод переход на следующий уровень.
/// Может загружать уровни и миры в прямой зависимости от
/// Количества уровней на мир(StagesByWorld)
/// </summary>
public void NextLevel()
{
if (Stage >= StagesByWorld)
{
World++;
Stage = 1;
}
else Stage++;
LoadLevel(World, Stage);
}
/// <summary>
/// Метод сброса уровня.
/// При сбросе также сбрасывает монеты и отнимает жизни
/// </summary>
[Obsolete("Используйте RespawnPlayer() для респавна игрока без сброса уровня и жизней.")]
public void ResetLevel()
{
Lives--;
LivesChanged?.Invoke(Lives);
Coins = 0;
CoinsChanged?.Invoke(Coins);
//print($"Lives: {lives}");
if (Lives > 0) LoadLevel(World, Stage);
else GameOver();
}
/// <summary>
/// Загрузка уровня по данным.
/// Загружает уровень по номерам мира и уровня
/// </summary>
/// <param name="world">Номер мира</param>
/// <param name="stage">Номер уровня</param>
public void LoadLevel(int world, int stage)
{
this.World = world;
this.Stage = stage;
string sceneName = $"{world}-{stage}";
if (SceneInBuildSettingsExists(sceneName))
{
LoadScene(sceneName);
}
else
{
// Можно заменить на другое поведение
GameWin();
// --
}
}
/// <summary>
/// Загрузка сцены по названию.
/// Вызывает одноименный метод в <see cref="SceneManager"/>.
/// Может загружать сцены с UI и без.
/// В сценах без UI игрок не спавнится
/// </summary>
/// <param name="sceneName">Строковое название сцены</param>
/// <param name="useUI">Использовать ли UI</param>
public void LoadScene(string sceneName, bool useUI = true)
{
_shouldSpawnPlayer = true;
SceneManager.LoadScene(sceneName);
if (useUI) SceneManager.LoadScene("Level UI", LoadSceneMode.Additive);
else
{
Light?.SetLight(LightMode.Off);
Plate.Blink();
_shouldSpawnPlayer = false;
}
}
/// <summary>
/// Метод проверки сцены на существование в настройках билда
/// </summary>
/// <param name="sceneName">Строковое название сцены</param>
/// <returns></returns>
private static bool SceneInBuildSettingsExists(string sceneName)
{
var count = SceneManager.sceneCountInBuildSettings;
for (var i = 0; i < count; i++)
{
var path = SceneUtility.GetScenePathByBuildIndex(i);
if (Path.GetFileNameWithoutExtension(path) == sceneName)
return true;
}
return false;
}
/// <summary>
/// Метод обработки события загрузки сцены.
/// При загрузке сцены проверяет её название на наличие "-" (уровень формата "1-1")
/// Если находит, спавнит игрока
/// Также обновляет номера мира и уровня
/// </summary>
/// <param name="scene">Сцена</param>
/// <param name="mode">Режим загрузки сцены</param>
private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
{
string currentSceneName = SceneManager.GetActiveScene().name;
if (!currentSceneName.Contains("-")) return;
TrySpawnPlayerForActiveScene();
if (currentSceneName == $"{World}-{Stage}") return;
string[] levelInfo = currentSceneName.Split("-");
World = Convert.ToInt32(levelInfo[0]);
Stage = Convert.ToInt32(levelInfo[1]);
}
#endregion
#region Player management
/// <summary>
/// Метод для спавна игрока.
/// Ищет первый спавнпоинт, спавнит игрока на его позиции.
/// Если на карте есть объект игрока, удаляет его и спавнит свой
/// </summary>
private void TrySpawnPlayerForActiveScene()
{
if (!_shouldSpawnPlayer) return;
PlayerSpawn spawn = FindFirstObjectByType<PlayerSpawn>();
Vector3 pos = spawn ? spawn.transform.position : new Vector3(0, 1.3f, 0);
Quaternion rot = spawn ? spawn.transform.rotation : Quaternion.identity;
PlayerObj = GameObject.FindGameObjectWithTag("Player");
if (PlayerObj != null) Destroy(PlayerObj);
GameObject prefab = ResolvePlayerPrefab();
if (prefab == null) return;
PlayerObj = Instantiate(prefab, pos, rot);
SceneManager.MoveGameObjectToScene(PlayerObj, SceneManager.GetActiveScene());
PlayerSpawned?.Invoke(PlayerObj);
}
/// <summary>
/// Метод респавна игрока.
/// Отнимает жизнь, если жизней не осталось — вызывает GameOver()
/// Спавнит игрока на активном чекпоинте или на дефолтной позиции
/// </summary>
public void RespawnPlayer()
{
// отнимем жизнь (оставляем текущее поведение); монеты не сбрасываем
Lives--;
LivesChanged?.Invoke(Lives);
if (Lives <= 0)
{
GameOver();
return;
}
// Определяем дефолтные позицию и направление из PlayerSpawn в сцене
PlayerSpawn spawn = FindFirstObjectByType<PlayerSpawn>();
Vector3 defaultPos = spawn ? spawn.transform.position : new Vector3(0, 1.3f, 0);
Vector2 defaultForward = spawn ? spawn.defaultForward : Vector2.right;
// Если есть активный чекпоинт — берём из него; иначе дефолт
Vector3 respawnPos = defaultPos;
Vector2 respawnForward = defaultForward;
if (CheckpointSystem.Instance != null)
{
respawnPos = CheckpointSystem.Instance.GetSpawnPosition(defaultPos);
respawnForward = CheckpointSystem.Instance.GetSpawnForward(defaultForward);
if (CheckpointSystem.Instance.Active != null)
{
respawnPos = CheckpointSystem.Instance.Active.GetSpawnPosition();
respawnForward = CheckpointSystem.Instance.Active.GetForward();
}
}
// Если потеряли ссылку — попробуем найти игрока в сцене
if (PlayerObj == null)
PlayerObj = GameObject.FindGameObjectWithTag("Player");
if (PlayerObj == null)
{
// Создадим нового игрока, если объект был уничтожен
GameObject prefab = ResolvePlayerPrefab();
if (prefab == null) return;
PlayerObj = Instantiate(prefab, respawnPos, Quaternion.identity);
SceneManager.MoveGameObjectToScene(PlayerObj, SceneManager.GetActiveScene());
PlayerSpawned?.Invoke(PlayerObj);
}
else
{
// Остановим движение и телепортируем
Rigidbody2D rb = PlayerObj.GetComponent<Rigidbody2D>();
if (rb != null)
{
rb.linearVelocity = Vector2.zero;
rb.angularVelocity = 0f;
}
PlayerObj.transform.position = respawnPos;
}
// Повернём в нужную сторону (по X)
if (PlayerObj == null) return;
if (respawnForward.x != 0)
{
PlayerObj.transform.localEulerAngles = respawnForward.x >= 0
? Vector3.zero
: new Vector3(0, 180f, 0);
}
// Восстановим здоровье
Health playerHealth = PlayerObj.GetComponent<Health>();
if (playerHealth != null)
playerHealth.ResetHealth();
}
#endregion
#region Coins and Lives management
/// <summary>
/// Метод добавления внутриигровой монеты
/// </summary>
public void AddCoin(int count = 1)
{
Coins += count;
// Можно заменить на другое поведение
if (Coins >= 100)
{
Coins -= 100;
AddLife();
}
// --
CoinsChanged?.Invoke(Coins);
}
// Можно заменить на другое поведение
/// <summary>
/// Метод для добавления жизни.
/// </summary>
private void AddLife()
{
Lives++;
LivesChanged?.Invoke(Lives);
}
// --
#endregion
}
}