Danil Iarosh 839a68aef1 Improve VFX, UI overlays, session statistics, COM reliability, audio mixer, and expand architecture guides
Подробный список изменений:
- Перенесены эффекты частиц из C#-кода в готовые префабы-ассеты (Vfx Prefabs) в папке Assets/Resources/Vfx/ с автоматической генерацией материалов в Editor.
- Исправлены ошибки duration на играющих системах частиц и розовые текстуры в GameVfxService.cs.
- Исправлена навигация и фокус кнопок на оверлеях ContinueGameOverlay и PrizeResultOverlay с автоматическим подсвечиванием кнопок лимонно-зеленым цветом при фокусе и проигрыванием звука.
- Добавлена проверка настроек в GameplayHudController.cs для скрытия ContinueGameOverlay, если продолжение игры отключено.
- Расширен сбор статистики: создана структура GameplaySessionRecord, добавлена история до 50 раундов в PrizeSessionStateService и JsonPrizeSessionStateRepository, выведен расчет средних показателей и список 5 последних раундов в сервисной статистике.
- Повышена надежность COM-транспорта: добавлен sleep (80мс) в Dispose для graceful shutdown потоков, реализован цикл переподключения до 10 попыток при обрыве связи.
- Интегрированы настройки громкости с микшером: изменен шаг изменения громкости на 5% (0.05) для удобства, добавлен статус-чек и логи в ApplyServiceVolumes, расширены юнит-тесты на GetFloat параметры.
- Созданы новые архитектурные гайды по аппаратному COM-транспорту и призовой экономике автомата в папке GUIDES/architecture/.
- Оптимизирован и обновлен RAG-индекс проекта.
2026-07-03 15:04:08 +03:00

366 lines
12 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 DanroJump.Audio;
using DanroJump.Hardware.Com;
using DanroJump.Prizes;
using DanroJump.SceneFlow;
using DanroJump.Settings;
using UnityEngine;
namespace DanroJump.Gameplay
{
/// <summary>
/// Хранит состояние текущего забега: счет, монеты, жизни, высоту и рекорд.
/// </summary>
public sealed class GameplaySession : IDisposable
{
private const string RecordHeightPrefsKey = "DanroJump.Gameplay.RecordHeight";
private const int DefaultStartingLives = 3;
private const int DefaultAdditionalLives = 1;
private const float HeightMetersPerScorePoint = 10f;
private static readonly TimeSpan GameOverOverlayReturnDelay = TimeSpan.FromSeconds(8f);
private readonly IPlayerController player;
private readonly IReadOnlyServiceSettings serviceSettings;
private readonly ISceneFlowService sceneFlow;
private readonly IPrizeFlowService prizeFlow;
private readonly IComCreditWallet creditWallet;
private readonly IGameAudioService audioService;
private readonly int startingLives;
private int baseScore;
private float savedRecordHeight;
public string SessionId { get; private set; } = CreateSessionId();
/// <summary>
/// Итоговые очки текущего забега: игровые начисления плюс бонус за максимальную высоту.
/// </summary>
public int Score => baseScore + HeightScore;
/// <summary>
/// Очки, начисленные за максимальную высоту текущего забега.
/// </summary>
public int HeightScore => Mathf.FloorToInt(MaxRunHeight / HeightMetersPerScorePoint);
/// <summary>
/// Монеты, собранные в текущем забеге.
/// </summary>
public int Coins { get; private set; }
/// <summary>
/// Количество смертей в текущем забеге.
/// </summary>
public int DeathCount { get; private set; }
/// <summary>
/// Оставшиеся жизни до завершения забега.
/// </summary>
public int RemainingLives { get; private set; }
/// <summary>
/// Текущее состояние gameplay-сессии.
/// </summary>
public GameplaySessionState State { get; private set; }
/// <summary>
/// Фактическая текущая высота игрока над стартом.
/// </summary>
public float CurrentHeight => player != null ? Mathf.Max(0f, player.Transform.position.y) : 0f;
/// <summary>
/// Максимальная высота, достигнутая в текущем забеге.
/// </summary>
public float MaxRunHeight => player != null ? Mathf.Max(0f, player.HighestY) : 0f;
/// <summary>
/// Лучшая высота текущего забега.
/// </summary>
public float BestHeight => MaxRunHeight;
/// <summary>
/// Абсолютный рекорд с учетом сохраненного значения и текущего забега.
/// </summary>
public float RecordHeight => Mathf.Max(savedRecordHeight, MaxRunHeight);
/// <summary>
/// Показывает, находится ли игрок в состоянии смерти или завершенного забега.
/// </summary>
public bool IsPlayerDead => State == GameplaySessionState.Respawning || State == GameplaySessionState.GameOver;
public int ContinueGameCost => ResolveContinueCost();
public int ContinueGameLives => ResolveAdditionalLives();
public bool CanContinueGame => State == GameplaySessionState.GameOver &&
IsContinueEnabled() &&
HasContinueBalance(ContinueGameCost);
public event Action Changed;
/// <summary>
/// Создает сессию и подписывается на события смерти/respawn игрока.
/// </summary>
public GameplaySession(
IPlayerController player,
[Zenject.InjectOptional] IReadOnlyServiceSettings serviceSettings = null,
[Zenject.InjectOptional] ISceneFlowService sceneFlow = null,
[Zenject.InjectOptional] IPrizeFlowService prizeFlow = null,
[Zenject.InjectOptional] IComCreditWallet creditWallet = null,
[Zenject.InjectOptional] IGameAudioService audioService = null)
{
this.player = player;
this.serviceSettings = serviceSettings;
this.sceneFlow = sceneFlow;
this.prizeFlow = prizeFlow;
this.creditWallet = creditWallet;
this.audioService = audioService;
startingLives = ResolveStartingLives();
RemainingLives = startingLives;
State = GameplaySessionState.Running;
savedRecordHeight = PlayerPrefs.GetFloat(RecordHeightPrefsKey, 0f);
if (this.player != null)
{
this.player.Died += HandlePlayerDied;
this.player.Respawned += HandlePlayerRespawned;
}
}
/// <summary>
/// Добавляет очки в текущий забег.
/// </summary>
public void AddScore(int amount)
{
if (amount <= 0)
{
return;
}
baseScore += amount;
Changed?.Invoke();
}
/// <summary>
/// Добавляет монеты в текущий забег.
/// </summary>
public void AddCoins(int amount)
{
if (amount <= 0)
{
return;
}
Coins += amount;
Changed?.Invoke();
}
/// <summary>
/// Полностью сбрасывает забег, сохранив рекорд при необходимости.
/// </summary>
public void ResetSession()
{
SaveRecordIfNeeded();
SessionId = CreateSessionId();
baseScore = 0;
Coins = 0;
DeathCount = 0;
RemainingLives = startingLives;
State = GameplaySessionState.Running;
player?.ResetRun();
Changed?.Invoke();
}
public bool TryContinueGame()
{
if (!CanContinueGame)
{
return false;
}
if (!TrySpendContinueBalance(ContinueGameCost))
{
Changed?.Invoke();
return false;
}
audioService?.Play(GameAudioEvent.ContinueAccepted);
sceneFlow?.CancelGameOverReturn();
RemainingLives = ContinueGameLives;
State = GameplaySessionState.Respawning;
if (player != null)
{
player.AllowRespawn();
player.Respawn();
}
else
{
State = GameplaySessionState.Running;
}
Changed?.Invoke();
return true;
}
/// <summary>
/// Сохраняет рекорд и отписывается от событий игрока.
/// </summary>
public void Dispose()
{
SaveRecordIfNeeded();
if (player == null)
{
return;
}
player.Died -= HandlePlayerDied;
player.Respawned -= HandlePlayerRespawned;
}
/// <summary>
/// Обновляет состояние сессии после смерти игрока.
/// </summary>
private void HandlePlayerDied(IPlayerController _)
{
audioService?.Play(GameAudioEvent.PlayerDeath);
DeathCount++;
RemainingLives = Mathf.Max(0, RemainingLives - 1);
State = RemainingLives > 0 ? GameplaySessionState.Respawning : GameplaySessionState.GameOver;
if (State == GameplaySessionState.GameOver)
{
audioService?.Play(GameAudioEvent.GameOver);
audioService?.PlayMusic(GameMusicCue.GameOver);
player?.BlockPendingRespawn();
var prizeResult = prizeFlow?.StartPrizeFlow(this) ?? default;
if (!prizeResult.HasPrize)
{
StartGameOverReturn(GameOverOverlayReturnDelay);
}
}
else
{
player?.ScheduleRespawn(player.DeathRespawnDelay);
}
SaveRecordIfNeeded();
Changed?.Invoke();
}
/// <summary>
/// Возвращает сессию в Running после respawn, если игра еще не закончена.
/// </summary>
private void HandlePlayerRespawned(IPlayerController _)
{
if (State != GameplaySessionState.GameOver)
{
State = GameplaySessionState.Running;
audioService?.Play(GameAudioEvent.PlayerRespawn);
audioService?.PlayMusic(GameMusicCue.Gameplay);
}
Changed?.Invoke();
}
/// <summary>
/// Читает стартовое количество жизней из сервисных настроек.
/// </summary>
private int ResolveStartingLives()
{
if (serviceSettings == null)
{
return DefaultStartingLives;
}
var configuredLives = serviceSettings.GetInt(ServiceSettingsKeys.StartingLives);
return Mathf.Max(1, configuredLives);
}
private int ResolveAdditionalLives()
{
if (serviceSettings == null)
{
return DefaultAdditionalLives;
}
return Mathf.Max(1, serviceSettings.GetInt(ServiceSettingsKeys.AdditionalLives));
}
private int ResolveContinueCost()
{
return serviceSettings != null
? Mathf.Max(0, serviceSettings.GetInt(ServiceSettingsKeys.ContinueGameCost))
: 0;
}
public bool IsContinueEnabled()
{
return serviceSettings != null && serviceSettings.GetBool(ServiceSettingsKeys.CanContinueGame);
}
private bool HasContinueBalance(int cost)
{
return IsFreePlay() || cost <= 0 || creditWallet != null && creditWallet.Credits >= cost;
}
private bool TrySpendContinueBalance(int cost)
{
if (IsFreePlay() || cost <= 0)
{
return true;
}
return creditWallet != null && creditWallet.TrySpendCredits(cost);
}
private bool IsFreePlay()
{
return serviceSettings != null && serviceSettings.GetBool(ServiceSettingsKeys.FreePlay);
}
/// <summary>
/// Сохраняет лучший результат по высоте в PlayerPrefs.
/// </summary>
private void SaveRecordIfNeeded()
{
if (MaxRunHeight <= savedRecordHeight)
{
return;
}
savedRecordHeight = MaxRunHeight;
PlayerPrefs.SetFloat(RecordHeightPrefsKey, savedRecordHeight);
PlayerPrefs.Save();
}
/// <summary>
/// Запускает возврат в главное меню после финальной смерти.
/// </summary>
private void StartGameOverReturn(TimeSpan delay)
{
if (sceneFlow == null)
{
Debug.LogWarning($"{nameof(GameplaySession)} cannot return to main menu because {nameof(ISceneFlowService)} is not bound.");
return;
}
sceneFlow.ReturnToMainMenuAfterGameOver(delay);
}
private static string CreateSessionId()
{
return Guid.NewGuid().ToString("N");
}
}
/// <summary>
/// Состояние игрового забега.
/// </summary>
public enum GameplaySessionState
{
Waiting = 0,
Running = 1,
Respawning = 2,
GameOver = 3,
}
}