Danil Iarosh d39d160d92 Implement physical prize dispensing, service stats, and QR screen timeout
- Добавлен асинхронный процесс аппаратной выдачи физических призов (хоппер и витрина) в GameplayHudController.
- Реализована блокировка выдачи по 10-секундному таймауту датчика хоппера с гарантированной отправкой команды отключения двигателя '300'.
- Реализован автоматический таймаут неактивности на экране QR-кода с последующим переходом в главное меню.
- Расширен сервис статистики PrizeSessionStateService новыми долгосрочными счетчиками (сыгранные игры, очки, монеты, сбои связи COM, физ. выдачи и ошибки).
- Обновлен построитель сводки статистики в меню настроек для отображения новых счетчиков и списка последних 5 сессий.
- Написаны EditMode тесты для проверки инкремента счетчиков и запуска выдачи призов.
2026-06-23 10:36:13 +03:00

283 lines
9.5 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 UnityEngine;
using UnityEngine.U2D.Animation;
using DanroJump.Audio;
namespace DanroJump.Gameplay
{
/// <summary>
/// Базовый класс противника с обработкой trigger- и collision-контакта с игроком.
/// </summary>
[RequireComponent(typeof(Collider2D))]
public abstract class EnemyBase : MonoBehaviour, IEnemy, IPlayerContactHandler
{
[SerializeField] private EnemySpawnKind spawnKind = EnemySpawnKind.GroundedOnly;
[SerializeField] [Min(0)] private int stompScore = 10;
[SerializeField] private float stompTopTolerance = 0.2f;
[SerializeField] [Range(0f, 1f)] private float stompMinimumHeightFactor = 0.55f;
private GameplaySession session;
private IGameAudioService audioService;
private Collider2D enemyCollider;
private SpriteSkin[] spriteSkins;
private Renderer[] visualRenderers;
private bool visualComponentsCached;
private bool visualWarningLogged;
/// <summary>
/// Тип мест, где этот prefab врага разрешено создавать.
/// </summary>
public EnemySpawnKind SpawnKind => spawnKind;
/// <summary>
/// True, если врага можно создавать на точках платформ.
/// </summary>
public bool CanSpawnOnPlatform => spawnKind == EnemySpawnKind.GroundedOnly || spawnKind == EnemySpawnKind.Universal;
/// <summary>
/// True, если врага можно создавать процедурно в поле, отдельно от платформ.
/// </summary>
public bool CanSpawnProcedurally => spawnKind == EnemySpawnKind.FlyingOnly || spawnKind == EnemySpawnKind.Universal;
/// <summary>
/// True, если противник сейчас должен реагировать на игрока.
/// </summary>
public bool IsActive { get; private set; }
/// <summary>
/// Получает игровую сессию для начисления очков за stomp.
/// </summary>
[Zenject.Inject]
public void Construct(
GameplaySession injectedSession,
[Zenject.InjectOptional] IGameAudioService injectedAudioService = null)
{
session = injectedSession;
audioService = injectedAudioService;
}
/// <summary>
/// Активирует противника при включении объекта.
/// </summary>
protected virtual void OnEnable()
{
ConfigureRuntimeVisuals();
ActivateEnemy();
}
/// <summary>
/// Деактивирует противника при выключении объекта.
/// </summary>
protected virtual void OnDisable()
{
DeactivateEnemy();
}
/// <summary>
/// Передает trigger-контакт с игроком в конкретную реализацию.
/// </summary>
private void OnTriggerEnter2D(Collider2D other)
{
if (PlayerContactUtility.TryGetAlivePlayer(other, out var player))
{
HandlePlayerContact(player, this);
}
}
/// <summary>
/// Передает физическое столкновение с игроком в конкретную реализацию.
/// </summary>
private void OnCollisionEnter2D(Collision2D collision)
{
if (PlayerContactUtility.TryGetAlivePlayer(collision.collider, out var player))
{
HandlePlayerContact(player, this);
}
}
/// <summary>
/// Включает противника.
/// </summary>
public virtual void ActivateEnemy()
{
IsActive = true;
audioService?.Play(GameAudioEvent.EnemySpawned, transform.position);
}
/// <summary>
/// Выключает противника.
/// </summary>
public virtual void DeactivateEnemy()
{
IsActive = false;
}
/// <summary>
/// Сбрасывает противника к активному состоянию.
/// </summary>
public virtual void ResetEnemy()
{
ConfigureRuntimeVisuals();
ActivateEnemy();
}
/// <summary>
/// Обрабатывает stomp сверху или убивает игрока при обычном контакте.
/// </summary>
public virtual void HandlePlayerContact(JumpPlayerController player, Component source)
{
if (!IsActive || player == null || player.IgnoresEnvironmentInteractions)
{
return;
}
if (player.TryIgnoreEnemy(this))
{
return;
}
if (IsStomp(player))
{
HandleStomp(player);
return;
}
audioService?.Play(GameAudioEvent.EnemyAttack, transform.position);
player.Kill();
}
/// <summary>
/// Реакция врага на успешное уничтожение прыжком сверху.
/// </summary>
protected virtual void HandleStomp(JumpPlayerController player)
{
session?.AddScore(stompScore);
player.Bounce(player.AutoJumpVelocity);
audioService?.Play(GameAudioEvent.EnemyDefeated, transform.position);
DeactivateEnemy();
gameObject.SetActive(false);
}
/// <summary>
/// Проверяет, что игрок падает и его GroundCheck находится выше верхней части противника.
/// </summary>
protected virtual bool IsStomp(JumpPlayerController player)
{
enemyCollider ??= GetComponent<Collider2D>();
var bounds = enemyCollider != null
? enemyCollider.bounds
: new Bounds(transform.position, Vector3.one);
var groundCheck = player.GroundCheckPosition;
var isAboveEnemyHorizontally =
groundCheck.x >= bounds.min.x - stompTopTolerance &&
groundCheck.x <= bounds.max.x + stompTopTolerance;
var stompLineY = Mathf.Lerp(bounds.min.y, bounds.max.y, stompMinimumHeightFactor);
var isInUpperZone =
groundCheck.y >= stompLineY - stompTopTolerance ||
player.PreviousGroundCheckY >= bounds.max.y - stompTopTolerance;
return player.VerticalVelocity <= 0.05f &&
isAboveEnemyHorizontally &&
isInUpperZone;
}
/// <summary>
/// Приводит 2D Animation-визуал врага к предсказуемому runtime-режиму для Player build.
/// </summary>
private void ConfigureRuntimeVisuals()
{
CacheVisualComponentsIfNeeded();
if (spriteSkins != null)
{
foreach (var spriteSkin in spriteSkins)
{
if (spriteSkin == null)
{
continue;
}
spriteSkin.alwaysUpdate = true;
spriteSkin.forceCpuDeformation = true;
}
}
if (visualRenderers == null)
{
return;
}
foreach (var visualRenderer in visualRenderers)
{
if (visualRenderer != null)
{
visualRenderer.allowOcclusionWhenDynamic = false;
}
}
if (!HasUsableVisual())
{
DisableEnemyColliders();
if (!visualWarningLogged)
{
Debug.LogWarning($"{name} enemy has no usable visual renderer. Enemy colliders were disabled.", this);
visualWarningLogged = true;
}
}
}
private void CacheVisualComponentsIfNeeded()
{
if (visualComponentsCached)
{
return;
}
spriteSkins = GetComponentsInChildren<SpriteSkin>(true);
visualRenderers = GetComponentsInChildren<Renderer>(true);
visualComponentsCached = true;
}
private bool HasUsableVisual()
{
if (visualRenderers == null || visualRenderers.Length == 0)
{
return false;
}
foreach (var visualRenderer in visualRenderers)
{
if (visualRenderer == null)
{
continue;
}
if (visualRenderer is SpriteRenderer spriteRenderer)
{
if (spriteRenderer.sprite != null)
{
return true;
}
continue;
}
return true;
}
return false;
}
private void DisableEnemyColliders()
{
var colliders = GetComponentsInChildren<Collider2D>(true);
foreach (var targetCollider in colliders)
{
if (targetCollider != null)
{
targetCollider.enabled = false;
}
}
}
}
}