Danro/Assets/Scripts/Behaviour/EnvironmentalCollectibleSpawner.cs
Danil Iarosh cb2e83a596
Some checks failed
Gitea Actions Demo / Explore-Gitea-Actions (push) Has been cancelled
feat(ui, prizes, spawners, editor): expand prize flow and add spawning/editor tooling
- доработан flow призовой витрины и связанного UI:
  добавлены `PrizeStandButtonView` и `RulesPrizeListPage`, обновлены
  `PrizeStandController`, `PrizeStandPrize`, `PrizeStandModal`,
  `PrizeStandSettingsModal`, `LevelPrizeSettingsModal`, `QRCodeSettingsModal`,
  `BalanceDisplay`, `ButtonTextFocusColor` и связанные prefab'ы,
  чтобы улучшить настройку, навигацию, отображение кнопок и prize flow
- расширена система настроек и сохранения значений:
  `OptionPicker` получил отдельный формат `ValueSelector`,
  обновлены `SettingsDatabase`, `SettingEntryDrawer`, `SettingUIItem`,
  `SettingsUIGenerator`, `ValueSelectorRow` и `MainSettingsDatabase.asset`,
  а сохранение option picker-значений, включая `gameplay.gameDifficulty`,
  исправлено и синхронизировано с остальными настройками
- добавлены универсальные спавнеры сущностей:
  реализованы `EntitySpawner` и `EntitySpawnerManager` с поддержкой
  весов префабов, точек спавна, заполнения по проценту, ограничений
  на количество и автоспавна при инициализации
- доработана логика игрового спавна и сложности:
  процент инициализационного спавна может зависеть от настройки
  `gameplay.gameDifficulty` с градациями 25/50/75/100,
  в одном цикле массового спавна повторно не используется та же точка,
  а gizmo спавнера теперь показывает только сами точки без линий
- упрощена работа в редакторе Unity и добавлены editor-инструменты:
  в `GameManager` лицензия пропускается при запуске в `UNITY_EDITOR`,
  а `HierarchySelectionCounter`, `HierarchySelectionCountOverlay`
  и `HierarchyBulkRenamerWindow` добавляют подсчет выделения,
  realtime overlay в `SceneView` и массовое переименование объектов
  по правилам нумерации из Unity Project Settings
- синхронизированы сцены, ресурсы и UI-ассеты под новый flow:
  обновлены `MainMenu`, `Level UI`, уровни `1-1` и `1-2`,
  строки настроек, кнопки, TMP/font assets, изображения UI,
  `prizes.json` и recovery-данные после правок интерфейса и логики
- очищены и переразложены часть внешних и служебных ресурсов:
  удалён пакет `Lava_Flowing_Shader`, переименованы/заменены
  отдельные UI-prefab'ы и добавлены сопутствующие проектные артефакты,
  которые сейчас используются обновлённой конфигурацией проекта
2026-04-28 14:05:05 +03:00

671 lines
24 KiB
C#
Raw Permalink 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.Collections;
using System.Collections.Generic;
using RR.Common.Entities.Props;
using RR.Game2D.Core.Managers;
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 bool overrideLifetime;
[Tooltip("Время жизни в секундах. 0 или меньше — бесконечно.")]
public float lifetimeOverride;
public int limit;
}
[Serializable]
public struct NamedSpawnEntry
{
[Tooltip("Имя, по которому триггер выбирает этот entry (например \"leaves\", \"bats\").")]
public string key;
public SpawnEntry entry;
[Tooltip("Мгновенно убрать все чужие объекты при активации.")]
public bool hardReplace;
}
[Header("Spawn Count")]
[SerializeField] private int fixedCount = 8;
[SerializeField] private int maxSpawnAttempts = 12;
[Tooltip("Задержка между спауном каждого объекта (секунды). 0 — все сразу.")]
[SerializeField] private Vector2 spawnDelay = new(0.15f, 0.4f);
[Header("Spawn Area")]
[SerializeField] private BoxCollider2D spawnArea;
[SerializeField] private Vector2 areaCenter;
[SerializeField] private Vector2 areaSize = new(8f, 4f);
[Header("Random Scale & Rotation")]
[Tooltip("Минимальный и максимальный uniform-масштаб при спауне.")]
[SerializeField] private Vector2 scaleRange = new(0.8f, 1.2f);
[Tooltip("Минимальный и максимальный угол поворота по Z (градусы) при спауне.")]
[SerializeField] private Vector2 rotationRange = new(0f, 0f);
[Header("Player")]
[SerializeField] private float minDistanceToPlayer = 1.5f;
[SerializeField] private float maxDistanceToPlayer = 25f;
private Transform _player;
[Header("Prefabs")]
[SerializeField] private SpawnEntry[] spawnEntries;
[Header("Trigger Entries")]
[Tooltip("Именованные SpawnEntry для вызова из триггеров. Используйте SetForcedEntryByName(string) в UnityEvent.")]
[SerializeField] private NamedSpawnEntry[] namedEntries;
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 GameObject _forcedPrefab;
private SpawnEntry? _forcedEntry;
private BoxCollider2D _overrideArea;
private Coroutine _spawnCoroutine;
private Coroutine _gradualReplaceCoroutine;
private void Start()
{
ResolvePlayer();
if (GameManager.Instance != null)
GameManager.Instance.PlayerSpawned += OnPlayerSpawned;
SubscribeToScoreSystem();
MaintainFixedCount();
}
private void OnEnable()
{
SubscribeToScoreSystem();
}
private void OnDisable()
{
foreach (EnvironmentalCollectibleBase collectible in _activeCollectibles)
{
if (collectible != null)
collectible.Collected -= HandleCollected;
}
if (GameManager.Instance != null)
GameManager.Instance.PlayerSpawned -= OnPlayerSpawned;
UnsubscribeFromScoreSystem();
}
#region Public API — смена типа спауна
/// <summary>
/// Мягко переключает спаунер на prefab (компонент). Старые объекты доживают.
/// </summary>
public void SetForcedPrefab(EnvironmentalCollectibleBase prefab)
{
ApplyForced(prefab != null ? prefab.gameObject : null, null, false);
}
/// <summary>
/// Жёстко переключает на prefab (компонент). Все «чужие» объекты убираются сразу.
/// </summary>
public void SetForcedPrefabHard(EnvironmentalCollectibleBase prefab)
{
ApplyForced(prefab != null ? prefab.gameObject : null, null, true);
}
/// <summary>
/// Мягко переключает спаунер на prefab (GameObject).
/// Удобно вызывать из UnityEvent — просто перетащить prefab в инспекторе.
/// </summary>
public void SetForcedPrefabByObject(GameObject prefabObject)
{
ApplyForced(prefabObject, null, false);
}
/// <summary>
/// Жёстко переключает на prefab (GameObject). Все «чужие» объекты убираются сразу.
/// </summary>
public void SetForcedPrefabByObjectHard(GameObject prefabObject)
{
ApplyForced(prefabObject, null, true);
}
/// <summary>
/// Мягко переключает на prefab по индексу в массиве spawnEntries.
/// </summary>
public void SetForcedPrefabByIndex(int index)
{
if (!TryGetEntry(index, out SpawnEntry entry)) return;
ApplyForced(entry.prefab.gameObject, entry, false);
}
/// <summary>
/// Жёстко переключает на prefab по индексу — все «чужие» убираются сразу.
/// </summary>
public void SetForcedPrefabHardByIndex(int index)
{
if (!TryGetEntry(index, out SpawnEntry entry)) return;
ApplyForced(entry.prefab.gameObject, entry, true);
}
/// <summary>
/// Переключает спаунер на полностью кастомный SpawnEntry (из кода).
/// </summary>
public void SetForcedEntry(SpawnEntry entry, bool hardReplace = false)
{
if (entry.prefab == null) return;
ApplyForced(entry.prefab.gameObject, entry, hardReplace);
}
/// <summary>
/// Переключает спаунер по строковому ключу из массива namedEntries.
/// Вызывайте из UnityEvent (EventTrigger) — Unity покажет поле ввода строки.
/// </summary>
public void SetForcedEntryByName(string key)
{
if (namedEntries == null || string.IsNullOrEmpty(key)) return;
for (int i = 0; i < namedEntries.Length; i++)
{
if (string.Equals(namedEntries[i].key, key, StringComparison.OrdinalIgnoreCase))
{
NamedSpawnEntry named = namedEntries[i];
if (named.entry.prefab == null) return;
ApplyForced(named.entry.prefab.gameObject, named.entry, named.hardReplace);
return;
}
}
Debug.LogWarning($"[{nameof(EnvironmentalCollectibleSpawner)}] Named entry '{key}' not found.", this);
}
/// <summary>
/// Мягко переключает тип спауна по ключу из namedEntries.
/// Существующие объекты другого типа убираются постепенно (по одному с задержкой)
/// и заменяются новыми.
/// </summary>
public void SetEntryByName(string key)
{
if (namedEntries == null || string.IsNullOrEmpty(key)) return;
for (int i = 0; i < namedEntries.Length; i++)
{
if (string.Equals(namedEntries[i].key, key, StringComparison.OrdinalIgnoreCase))
{
NamedSpawnEntry named = namedEntries[i];
if (named.entry.prefab == null) return;
_forcedPrefab = named.entry.prefab.gameObject;
_forcedEntry = named.entry;
StartGradualReplace();
return;
}
}
Debug.LogWarning($"[{nameof(EnvironmentalCollectibleSpawner)}] Named entry '{key}' not found.", this);
}
/// <summary>
/// Возвращает спаунер в смешанный режим (все записи из spawnEntries).
/// </summary>
public void ClearForcedPrefab()
{
_forcedPrefab = null;
_forcedEntry = null;
MaintainFixedCount();
}
/// <summary>
/// Временно подменяет область спауна (например, из триггер-зоны).
/// Передайте null чтобы вернуть оригинальную область.
/// </summary>
public void SetOverrideArea(BoxCollider2D area)
{
_overrideArea = area;
}
#endregion
private void ApplyForced(GameObject prefabObj, SpawnEntry? entry, bool hardReplace)
{
_forcedPrefab = prefabObj;
_forcedEntry = entry;
RefreshActiveCollectibles();
MaintainFixedCount();
}
private bool TryGetEntry(int index, out SpawnEntry entry)
{
entry = default;
if (spawnEntries == null || index < 0 || index >= spawnEntries.Length)
return false;
entry = spawnEntries[index];
return entry.prefab != null;
}
private void MaintainFixedCount()
{
if (_spawnCoroutine != null)
StopCoroutine(_spawnCoroutine);
if (!gameObject.activeInHierarchy) return;
_spawnCoroutine = StartCoroutine(SpawnRoutine());
}
private IEnumerator SpawnRoutine()
{
int safety = fixedCount * Mathf.Max(1, maxSpawnAttempts);
bool isFirst = true;
while (_activeCollectibles.Count < fixedCount && safety > 0)
{
// Задержка перед каждым спауном (кроме первого — чтобы был хоть один сразу)
if (!isFirst && spawnDelay.y > 0f)
yield return new WaitForSeconds(UnityEngine.Random.Range(spawnDelay.x, spawnDelay.y));
isFirst = false;
if (!TrySpawnOne())
break;
safety--;
}
_spawnCoroutine = null;
}
private void StartGradualReplace()
{
if (_gradualReplaceCoroutine != null)
StopCoroutine(_gradualReplaceCoroutine);
if (!gameObject.activeInHierarchy) return;
_gradualReplaceCoroutine = StartCoroutine(GradualReplaceRoutine());
}
private IEnumerator GradualReplaceRoutine()
{
if (_forcedPrefab == null)
{
_gradualReplaceCoroutine = null;
yield break;
}
string forcedName = _forcedPrefab.name;
while (true)
{
EnvironmentalCollectibleBase toRemove = null;
foreach (EnvironmentalCollectibleBase c in _activeCollectibles)
{
if (c == null) continue;
if (_prefabByCollectible.TryGetValue(c, out GameObject p) && p.name != forcedName)
{
toRemove = c;
break;
}
}
if (toRemove == null) break;
DespawnCollectible(toRemove);
MaintainFixedCount();
if (spawnDelay.y > 0f)
yield return new WaitForSeconds(UnityEngine.Random.Range(spawnDelay.x, spawnDelay.y));
else
yield return null;
}
_gradualReplaceCoroutine = null;
}
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 = Instantiate(prefab, spawnPosition, Quaternion.identity, transform);
if (instance == null) return false;
// Случайный масштаб и поворот
float scale = UnityEngine.Random.Range(scaleRange.x, scaleRange.y);
instance.transform.localScale = Vector3.one * scale;
float angle = UnityEngine.Random.Range(rotationRange.x, rotationRange.y);
instance.transform.rotation = Quaternion.Euler(0f, 0f, angle);
EnvironmentalCollectibleBase collectible = instance.GetComponent<EnvironmentalCollectibleBase>();
if (collectible == null)
{
Debug.LogError($"[{nameof(EnvironmentalCollectibleSpawner)}] Prefab {prefab.name} is missing EnvironmentalCollectibleBase.", prefab);
Destroy(instance);
return false;
}
collectible.Collected -= HandleCollected;
collectible.Collected += HandleCollected;
collectible.InitializeForSpawn(
spawnPosition,
entry.overridePoints ? entry.pointsOverride : null,
entry.overrideLifetime ? entry.lifetimeOverride : 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);
if (HasReachedSharedCollectibleLimit())
{
DespawnCollectiblesBlockedBySharedLimit();
MaintainFixedCount();
return;
}
MaintainFixedCount();
}
private bool TryPickSpawnEntry(out SpawnEntry entry)
{
// Если задан полный SpawnEntry — используем его напрямую
if (_forcedEntry.HasValue)
{
SpawnEntry forced = _forcedEntry.Value;
if (forced.prefab == null)
{
entry = default;
return false;
}
if (IsBlockedBySharedCollectibleLimit(forced.prefab))
{
entry = default;
return false;
}
// Проверяем лимит
if (forced.limit > 0 &&
_activeCountByPrefab.TryGetValue(forced.prefab.gameObject, out int fc) &&
fc >= forced.limit)
{
entry = default;
return false;
}
entry = forced;
return true;
}
entry = default;
if (spawnEntries == null || spawnEntries.Length == 0)
return false;
string forcedName = _forcedPrefab != null ? _forcedPrefab.name : null;
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 (forcedName != null && prefab.name != forcedName) continue;
if (IsBlockedBySharedCollectibleLimit(current.prefab)) continue;
if (current.limit > 0 &&
_activeCountByPrefab.TryGetValue(prefab, out int activeCount) &&
activeCount >= current.limit)
{
continue;
}
totalWeight += current.weight;
}
// Фильтрация по _forcedPrefab — если prefab не найден в spawnEntries, создаём временный entry
if (totalWeight <= 0 && _forcedPrefab != null)
{
EnvironmentalCollectibleBase comp = _forcedPrefab.GetComponent<EnvironmentalCollectibleBase>();
if (comp != null && !IsBlockedBySharedCollectibleLimit(comp))
{
entry = new SpawnEntry { prefab = comp, weight = 1 };
return true;
}
}
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 (forcedName != null && prefab.name != forcedName) continue;
if (IsBlockedBySharedCollectibleLimit(current.prefab)) 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 (_overrideArea != null)
return _overrideArea.bounds;
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 (GameManager.Instance?.PlayerObj != null)
{
_player = GameManager.Instance.PlayerObj.transform;
return;
}
// Fallback на случай, если GameManager ещё не проинициализирован
GameObject playerObject = GameObject.FindGameObjectWithTag("Player");
if (playerObject != null)
_player = playerObject.transform;
}
private void OnPlayerSpawned(GameObject playerObj)
{
_player = playerObj != null ? playerObj.transform : null;
}
private void RefreshActiveCollectibles()
{
if (_forcedPrefab == null)
return;
string forcedName = _forcedPrefab.name;
_refreshBuffer.Clear();
foreach (EnvironmentalCollectibleBase collectible in _activeCollectibles)
{
if (collectible == null) continue;
if (_prefabByCollectible.TryGetValue(collectible, out GameObject prefab) && prefab.name != forcedName)
_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);
}
_activeCollectibles.Remove(collectible);
_prefabByCollectible.Remove(collectible);
Destroy(collectible.gameObject);
}
private void SubscribeToScoreSystem()
{
if (ScoreSystem.Instance != null)
{
ScoreSystem.Instance.SharedEnvironmentalCollectiblesChanged -= OnSharedEnvironmentalCollectiblesChanged;
ScoreSystem.Instance.SharedEnvironmentalCollectiblesChanged += OnSharedEnvironmentalCollectiblesChanged;
}
}
private void UnsubscribeFromScoreSystem()
{
if (ScoreSystem.Instance != null)
{
ScoreSystem.Instance.SharedEnvironmentalCollectiblesChanged -= OnSharedEnvironmentalCollectiblesChanged;
}
}
private void OnSharedEnvironmentalCollectiblesChanged(int collected, int maxCount)
{
if (maxCount > 0 && collected >= maxCount)
{
DespawnCollectiblesBlockedBySharedLimit();
}
MaintainFixedCount();
}
private void DespawnCollectiblesBlockedBySharedLimit()
{
_refreshBuffer.Clear();
foreach (EnvironmentalCollectibleBase collectible in _activeCollectibles)
{
if (collectible != null && IsBlockedBySharedCollectibleLimit(collectible))
{
_refreshBuffer.Add(collectible);
}
}
for (int i = 0; i < _refreshBuffer.Count; i++)
{
DespawnCollectible(_refreshBuffer[i]);
}
_refreshBuffer.Clear();
}
private static bool HasReachedSharedCollectibleLimit()
{
return ScoreSystem.Instance != null && ScoreSystem.Instance.HasReachedSharedEnvironmentalCollectibleLimit;
}
private static bool IsBlockedBySharedCollectibleLimit(EnvironmentalCollectibleBase collectible)
{
return collectible != null && collectible.CountsTowardsSharedCollectionLimit && HasReachedSharedCollectibleLimit();
}
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);
}
}
}