Danro/Assets/Scripts/Behaviour/EnvironmentalCollectibleSpawner.cs
Danil Iarosh de87b318b7
Some checks failed
Gitea Actions Demo / Explore-Gitea-Actions (push) Has been cancelled
feat: add ScoreSystem, environmental collectibles, object pool and spawner
- Добавлен `ScoreSystem` — singleton-компонент, отслеживающий монеты, убитых врагов и экологические очки;
  финальный счёт вычисляется по формуле `Score = E × (P + 45C + 90K + 10K²) − 0.5T`
  с раздельными множителями для победы и проигрыша.
- Добавлен `EnvironmentalCollectibleBase` — абстрактный базовый класс для подбираемых экологических объектов:
  срабатывает по триггеру, поддерживает пулинг, при подборе регистрирует очки в `ScoreSystem`.
- Добавлен `LeafCollectible` — листик с синусоидальным горизонтальным дрейфом, вращением и детекцией земли.
- Добавлен `BatCollectible` — летучая мышь с синусоидальным вертикальным движением, ограниченным горизонтальным
  блужданием и случайными сменами направления; переворачивает спрайт по X при смене направления.
- Добавлен `EnvironmentalPool` — простой объектный пул по prefab-ключу с прогревом (`Warmup`) и возвратом объектов.
- Добавлен `EnvironmentalCollectibleSpawner` — взвешенный случайный спаунер, поддерживающий фиксированное количество
  активных объектов; поддерживает лимиты по типу, принудительный выбор одного prefab и область спауна через
  `BoxCollider2D` или ручной `Bounds`.
- В `CoinCollectible.TryPickup` добавлен вызов `ScoreSystem.Instance?.RegisterCoinCollected()`.
- В `EnemyController.Die` добавлен guard через флаг `_isDead` для предотвращения повторного срабатывания;
  добавлен вызов `ScoreSystem.Instance?.RegisterEnemyKilled()`.
- В `GameManager` добавлен вызов `ScoreSystem.Instance?.ResetStats()` при старте новой игры и при потере жизни.
2026-03-03 09:14:49 +03:00

330 lines
11 KiB
C#

using System;
using System.Collections.Generic;
using RR.Common.Entities.Props;
using UnityEngine;
namespace RR.Game2D.Behaviour
{
public sealed class EnvironmentalCollectibleSpawner : MonoBehaviour
{
[Serializable]
public struct SpawnEntry
{
public EnvironmentalCollectibleBase prefab;
public int weight;
public bool overridePoints;
public int pointsOverride;
public int limit;
}
[Header("Spawn Count")]
[SerializeField] private int fixedCount = 8;
[SerializeField] private int maxSpawnAttempts = 12;
[Header("Spawn Area")]
[SerializeField] private BoxCollider2D spawnArea;
[SerializeField] private Vector2 areaCenter;
[SerializeField] private Vector2 areaSize = new(8f, 4f);
[Header("Player")]
[SerializeField] private Transform player;
[SerializeField] private string playerTag = "Player";
[SerializeField] private float minDistanceToPlayer = 1.5f;
[SerializeField] private float maxDistanceToPlayer = 25f;
[Header("Prefabs")]
[SerializeField] private bool usePooling = true;
[SerializeField] private SpawnEntry[] spawnEntries;
private readonly HashSet<EnvironmentalCollectibleBase> _activeCollectibles = new();
private readonly Dictionary<EnvironmentalCollectibleBase, GameObject> _prefabByCollectible = new();
private readonly Dictionary<GameObject, int> _activeCountByPrefab = new();
private readonly List<EnvironmentalCollectibleBase> _refreshBuffer = new();
private EnvironmentalPool _pool;
private GameObject _forcedPrefab;
private void Awake()
{
if (usePooling)
{
_pool = GetComponent<EnvironmentalPool>();
if (_pool == null)
_pool = gameObject.AddComponent<EnvironmentalPool>();
}
}
private void Start()
{
ResolvePlayer();
WarmupPool();
MaintainFixedCount();
}
private void OnDisable()
{
foreach (EnvironmentalCollectibleBase collectible in _activeCollectibles)
{
if (collectible != null)
collectible.Collected -= HandleCollected;
}
}
public void SetSingleSpawnPrefab(EnvironmentalCollectibleBase prefab)
{
_forcedPrefab = prefab != null ? prefab.gameObject : null;
RefreshActiveCollectibles();
MaintainFixedCount();
}
public void SetSingleSpawnPrefabByIndex(int index)
{
if (spawnEntries == null || index < 0 || index >= spawnEntries.Length)
return;
SpawnEntry entry = spawnEntries[index];
SetSingleSpawnPrefab(entry.prefab);
}
public void ClearSingleSpawnPrefab()
{
_forcedPrefab = null;
MaintainFixedCount();
}
private void MaintainFixedCount()
{
int safety = fixedCount * Mathf.Max(1, maxSpawnAttempts);
while (_activeCollectibles.Count < fixedCount && safety > 0)
{
if (!TrySpawnOne())
break;
safety--;
}
}
private bool TrySpawnOne()
{
ResolvePlayer();
if (!TryPickSpawnEntry(out SpawnEntry entry))
return false;
if (!TryFindSpawnPosition(out Vector3 spawnPosition))
return false;
GameObject prefab = entry.prefab.gameObject;
GameObject instance = usePooling && _pool != null
? _pool.Get(prefab, spawnPosition, Quaternion.identity)
: Instantiate(prefab, spawnPosition, Quaternion.identity, transform);
if (instance == null) return false;
EnvironmentalCollectibleBase collectible = instance.GetComponent<EnvironmentalCollectibleBase>();
if (collectible == null)
{
Debug.LogError($"[{nameof(EnvironmentalCollectibleSpawner)}] Prefab {prefab.name} is missing EnvironmentalCollectibleBase.", prefab);
if (!usePooling)
Destroy(instance);
return false;
}
collectible.transform.SetParent(transform, true);
collectible.Collected -= HandleCollected;
collectible.Collected += HandleCollected;
collectible.InitializeForSpawn(_pool, prefab, spawnPosition, entry.overridePoints ? entry.pointsOverride : null);
_activeCollectibles.Add(collectible);
_prefabByCollectible[collectible] = prefab;
if (_activeCountByPrefab.TryGetValue(prefab, out int currentCount))
_activeCountByPrefab[prefab] = currentCount + 1;
else
_activeCountByPrefab[prefab] = 1;
return true;
}
private void HandleCollected(EnvironmentalCollectibleBase collectible)
{
if (collectible == null) return;
collectible.Collected -= HandleCollected;
if (_prefabByCollectible.TryGetValue(collectible, out GameObject prefab) &&
_activeCountByPrefab.TryGetValue(prefab, out int currentCount))
{
_activeCountByPrefab[prefab] = Mathf.Max(0, currentCount - 1);
}
_activeCollectibles.Remove(collectible);
_prefabByCollectible.Remove(collectible);
MaintainFixedCount();
}
private bool TryPickSpawnEntry(out SpawnEntry entry)
{
entry = default;
if (spawnEntries == null || spawnEntries.Length == 0)
return false;
int totalWeight = 0;
for (int i = 0; i < spawnEntries.Length; i++)
{
SpawnEntry current = spawnEntries[i];
if (current.prefab == null || current.weight <= 0) continue;
GameObject prefab = current.prefab.gameObject;
if (_forcedPrefab != null && prefab != _forcedPrefab) continue;
if (current.limit > 0 &&
_activeCountByPrefab.TryGetValue(prefab, out int activeCount) &&
activeCount >= current.limit)
{
continue;
}
totalWeight += current.weight;
}
if (totalWeight <= 0)
return false;
int pick = UnityEngine.Random.Range(0, totalWeight);
for (int i = 0; i < spawnEntries.Length; i++)
{
SpawnEntry current = spawnEntries[i];
if (current.prefab == null || current.weight <= 0) continue;
GameObject prefab = current.prefab.gameObject;
if (_forcedPrefab != null && prefab != _forcedPrefab) continue;
if (current.limit > 0 &&
_activeCountByPrefab.TryGetValue(prefab, out int activeCount) &&
activeCount >= current.limit)
{
continue;
}
pick -= current.weight;
if (pick < 0)
{
entry = current;
return true;
}
}
return false;
}
private bool TryFindSpawnPosition(out Vector3 spawnPosition)
{
Bounds bounds = GetSpawnBounds();
for (int i = 0; i < maxSpawnAttempts; i++)
{
float x = UnityEngine.Random.Range(bounds.min.x, bounds.max.x);
float y = UnityEngine.Random.Range(bounds.min.y, bounds.max.y);
spawnPosition = new Vector3(x, y, 0f);
if (player == null)
return true;
float distance = Vector2.Distance(player.position, spawnPosition);
if (distance < minDistanceToPlayer) continue;
if (maxDistanceToPlayer > 0f && distance > maxDistanceToPlayer) continue;
return true;
}
spawnPosition = transform.position;
return false;
}
private Bounds GetSpawnBounds()
{
if (spawnArea != null)
return spawnArea.bounds;
Vector3 center = transform.TransformPoint(areaCenter);
Vector3 size = new(areaSize.x, areaSize.y, 0f);
return new Bounds(center, size);
}
private void ResolvePlayer()
{
if (player != null) return;
GameObject playerObject = GameObject.FindGameObjectWithTag(playerTag);
if (playerObject != null)
player = playerObject.transform;
}
private void WarmupPool()
{
if (!usePooling || _pool == null || spawnEntries == null) return;
for (int i = 0; i < spawnEntries.Length; i++)
{
SpawnEntry entry = spawnEntries[i];
if (entry.prefab == null) continue;
_pool.Warmup(entry.prefab.gameObject, Mathf.Max(1, fixedCount));
}
}
private void RefreshActiveCollectibles()
{
if (_forcedPrefab == null)
return;
_refreshBuffer.Clear();
foreach (EnvironmentalCollectibleBase collectible in _activeCollectibles)
{
if (collectible == null) continue;
if (_prefabByCollectible.TryGetValue(collectible, out GameObject prefab) && prefab != _forcedPrefab)
_refreshBuffer.Add(collectible);
}
for (int i = 0; i < _refreshBuffer.Count; i++)
{
DespawnCollectible(_refreshBuffer[i]);
}
_refreshBuffer.Clear();
}
private void DespawnCollectible(EnvironmentalCollectibleBase collectible)
{
if (collectible == null) return;
collectible.Collected -= HandleCollected;
if (_prefabByCollectible.TryGetValue(collectible, out GameObject prefab))
{
if (_activeCountByPrefab.TryGetValue(prefab, out int currentCount))
_activeCountByPrefab[prefab] = Mathf.Max(0, currentCount - 1);
if (usePooling && _pool != null)
_pool.Return(prefab, collectible.gameObject);
else
Destroy(collectible.gameObject);
}
_activeCollectibles.Remove(collectible);
_prefabByCollectible.Remove(collectible);
}
private void OnDrawGizmosSelected()
{
Bounds bounds = GetSpawnBounds();
Gizmos.color = new Color(0.15f, 0.8f, 0.25f, 0.35f);
Gizmos.DrawCube(bounds.center, bounds.size);
Gizmos.color = new Color(0.15f, 0.8f, 0.25f, 1f);
Gizmos.DrawWireCube(bounds.center, bounds.size);
}
}
}