Danro/Assets/Scripts/Managers/GameManager.cs
Danil Iarosh 942c5dd9fa chore(project): sync startup and tooling updates
- добавлены проектные инструкции AGENTS.md:
  зафиксированы правила работы с документацией, сообщениями коммитов,
  MCP-инструментами и Unity MCP workflow для дальнейшей разработки
- проверка лицензии вынесена из GameManager в отдельный StartupLicenseManager:
  код WinAPI, запуск RRLauncher.exe, обработка таймаута, возврат фокуса,
  установка fullscreen-режима и клик в центр перенесены в отдельный
  долгоживущий компонент
- обновлена инициализация глобальных сервисов:
  GlobalsManager теперь добавляет и инициализирует StartupLicenseManager,
  а Globals.prefab содержит новый компонент рядом с остальными runtime-сервисами
- обновлены Unity Editor и зависимости проекта:
  версия проекта поднята до 6000.3.14f1, обновлены ai.inference,
  collab-proxy, inputsystem, timeline, visualscripting и связанные lock-записи
- синхронизированы проектные настройки и generated assets после обновления Unity:
  обновлены ProjectSettings, URP global settings, TMP/font assets,
  tile/prefab meta-данные и связанные ресурсы сцен
- скорректирован MainMenu UI:
  символ валюты заменен на текстовое обозначение руб., а также сохранены
  небольшие Unity-сериализованные изменения layout/scrollbar-значений
- добавлены правила .gitignore для локальных служебных артефактов:
  Codex/RAG-индекса, .NET SDK telemetry/cache и Unity recovery-сцен
- удалены из отслеживания ранее попавшие в репозиторий файлы .dotnet и Assets/_Recovery:
  новые служебные файлы этих каталогов больше не будут засорять рабочее дерево
2026-05-15 14:31:53 +03:00

750 lines
28 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 int _lastLifeAwardCoinThreshold = 0;
private float? _pendingWinGameTime;
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;
StartIdleEffects();
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;
_lastLifeAwardCoinThreshold = 0;
_pendingWinGameTime = null;
CoinsChanged?.Invoke(Coins);
ScoreSystem.Instance?.ResetStats();
_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;
_lastLifeAwardCoinThreshold = 0;
_pendingWinGameTime = null;
CoinsChanged?.Invoke(Coins);
ScoreSystem.Instance?.ResetStats();
// Сбрасываем таймер бездействия
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, 30f);
GameOverEvent?.Invoke(totalTime);
}
// --
private void GameWin()
{
float totalTime = _pendingWinGameTime ?? (Time.unscaledTime - _gameStartTime);
// Отключаем отслеживание бездействия
if (Inactivity != null) Inactivity.DisableInactivityCheck();
Light?.SetLight(LightMode.FinalWave, 30f);
GameWonEvent?.Invoke(totalTime);
_pendingWinGameTime = null;
}
/// <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;
_lastLifeAwardCoinThreshold = 0;
_pendingWinGameTime = null;
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();
// --
}
}
public bool IsCurrentLevelFinal()
{
int nextWorld = World;
int nextStage = Stage;
if (nextStage >= StagesByWorld)
{
nextWorld++;
nextStage = 1;
}
else
{
nextStage++;
}
return !SceneInBuildSettingsExists($"{nextWorld}-{nextStage}");
}
public float GetCurrentGameTime()
{
return Time.unscaledTime - _gameStartTime;
}
public void SetPendingWinGameTime(float gameTime)
{
_pendingWinGameTime = Mathf.Max(0f, gameTime);
}
public float GetPendingOrCurrentWinGameTime()
{
return _pendingWinGameTime ?? GetCurrentGameTime();
}
/// <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
{
_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 == "MainMenu")
{
StartIdleEffects();
return;
}
if (!currentSceneName.Contains("-")) return;
TrySpawnPlayerForActiveScene();
if (currentSceneName == $"{World}-{Stage}") return;
string[] levelInfo = currentSceneName.Split("-");
World = Convert.ToInt32(levelInfo[0]);
Stage = Convert.ToInt32(levelInfo[1]);
}
/// <summary>
/// Запускает подсветку и мигание шляпы в режиме ожидания (главное меню).
/// Гарантированно останавливает всё предыдущее и запускает заново.
/// Безопасно вызывать повторно.
/// </summary>
public void StartIdleEffects()
{
if (!Light) return;
Light.StopBlink(enableHat: false);
Light.StopSequence();
Light.Blink();
Light.SetLight(LightMode.DiagonalWave)
.Then(LightMode.DiagonalWave2)
.Then(LightMode.DiagonalWave3)
.Then(LightMode.Rainbow)
.Then(LightMode.WaveUp)
.Then(LightMode.FallingRows)
.Then(LightMode.OrangeWave)
.Then(LightMode.SeaWave)
.Loop();
}
#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)
{
if (count <= 0) return;
Coins += count;
int earnedLifeThreshold = Coins / 100;
while (_lastLifeAwardCoinThreshold < earnedLifeThreshold)
{
AddLife();
_lastLifeAwardCoinThreshold++;
}
CoinsChanged?.Invoke(Coins);
}
// Можно заменить на другое поведение
/// <summary>
/// Метод для добавления жизни.
/// </summary>
private void AddLife()
{
Lives++;
LivesChanged?.Invoke(Lives);
}
// --
#endregion
}
}