Подробные изменения: - Добавлены сцены Bootstrap, MainMenu и Game с текущей вертикальной 9:16 структурой проекта. - Добавлены runtime-скрипты gameplay-архитектуры: DI через Extenject, спавнер платформ, прогрессия этапов, one-way платформы, игрок, камера и оборудование JumpPad. - Добавлены ScriptableObject-этапы сложности, weighted-настройки платформ, оборудования, противников и collectibles. - Добавлены UI Toolkit ресурсы главного меню, preview-материалы, шрифты, спрайты, анимации и игровые prefab-ассеты. - Перенесена и оформлена новая COM-система для работы с аппаратными портами и StreamingAssets. - Обновлены проектные гайды, AGENTS.md, правила Git/Git LFS, .gitignore, Unity Packages и ProjectSettings. - Удалён локальный skill start-required-mcp-servers из репозитория.
359 lines
13 KiB
C#
359 lines
13 KiB
C#
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
using UnityEngine.Serialization;
|
|
using Zenject;
|
|
|
|
namespace DanroJump.Gameplay
|
|
{
|
|
public sealed class PlatformSpawner : MonoBehaviour
|
|
{
|
|
[Min(1)]
|
|
[SerializeField] private int initialPlatformCount = 34;
|
|
[Min(0f)]
|
|
[SerializeField] private float horizontalPadding = 0.85f;
|
|
[FormerlySerializedAs("recycleDistanceBelowCamera")]
|
|
[SerializeField]
|
|
[Min(0f)]
|
|
[Tooltip("How far below the camera a platform must fall before it is recycled. Increase this to make platforms disappear lower.")]
|
|
private float recycleDistanceBelowCameraBottom = 12f;
|
|
[SerializeField] private bool useHardwareRandomSeed = true;
|
|
[SerializeField] private int randomSeed = 1207;
|
|
|
|
private readonly List<PlatformController> platforms = new();
|
|
private readonly Dictionary<PlatformController, GameObject> prefabByPlatform = new();
|
|
private DiContainer container;
|
|
private Camera gameplayCamera;
|
|
private GameplayDifficultyProgression difficultyProgression;
|
|
private PlatformContentSpawner contentSpawner;
|
|
private JumpPlayerController playerController;
|
|
private System.Random random;
|
|
private int currentRandomSeed;
|
|
private float highestPlatformY;
|
|
private int nextSpawnIndex;
|
|
|
|
public int CurrentRandomSeed => currentRandomSeed;
|
|
public float CurrentGenerationY => highestPlatformY;
|
|
public string CurrentGenerationStage => GetStageName(highestPlatformY);
|
|
public float CurrentGenerationStageProgress => ProgressionForDiagnostics != null ? ProgressionForDiagnostics.GetStageProgress(highestPlatformY) : 0f;
|
|
public float CurrentPlatformDistribution => ProgressionForDiagnostics != null ? ProgressionForDiagnostics.GetHorizontalDistribution(highestPlatformY) : 0f;
|
|
|
|
private GameplayDifficultyProgression ProgressionForDiagnostics =>
|
|
difficultyProgression != null ? difficultyProgression : FindFirstObjectByType<GameplayDifficultyProgression>();
|
|
|
|
[Inject]
|
|
public void Construct(
|
|
DiContainer injectedContainer,
|
|
JumpPlayerController playerController,
|
|
Camera injectedGameplayCamera,
|
|
PlatformContentSpawner injectedContentSpawner,
|
|
GameplayDifficultyProgression injectedDifficultyProgression)
|
|
{
|
|
container = injectedContainer;
|
|
this.playerController = playerController;
|
|
gameplayCamera = injectedGameplayCamera;
|
|
contentSpawner = injectedContentSpawner;
|
|
difficultyProgression = injectedDifficultyProgression;
|
|
}
|
|
|
|
private void Awake()
|
|
{
|
|
currentRandomSeed = useHardwareRandomSeed ? CreateHardwareRandomSeed() : randomSeed;
|
|
random = new System.Random(currentRandomSeed);
|
|
Debug.Log($"{nameof(PlatformSpawner)} random seed: {currentRandomSeed} ({(useHardwareRandomSeed ? "hardware" : "manual")}).", this);
|
|
}
|
|
|
|
private void Start()
|
|
{
|
|
if (!ValidateRuntimeState())
|
|
{
|
|
return;
|
|
}
|
|
|
|
GenerateInitialPlatforms();
|
|
}
|
|
|
|
private void Update()
|
|
{
|
|
RecyclePlatformsBelowCamera();
|
|
}
|
|
|
|
private void OnDestroy()
|
|
{
|
|
if (contentSpawner == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var platform in platforms)
|
|
{
|
|
contentSpawner.ClearSpawnedContent(platform);
|
|
}
|
|
|
|
prefabByPlatform.Clear();
|
|
}
|
|
|
|
private void GenerateInitialPlatforms()
|
|
{
|
|
highestPlatformY = difficultyProgression.InitialSpawnY;
|
|
nextSpawnIndex = 0;
|
|
SpawnPlatform(new Vector2(0f, highestPlatformY), true);
|
|
|
|
for (var index = 1; index < initialPlatformCount; index++)
|
|
{
|
|
highestPlatformY += RandomVerticalSpacing();
|
|
SpawnPlatform(new Vector2(RandomHorizontalPosition(highestPlatformY), highestPlatformY), false);
|
|
}
|
|
}
|
|
|
|
private void RecyclePlatformsBelowCamera()
|
|
{
|
|
if (gameplayCamera == null || platforms.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var bottomLimit = gameplayCamera.transform.position.y - recycleDistanceBelowCameraBottom;
|
|
|
|
for (var index = 0; index < platforms.Count; index++)
|
|
{
|
|
var platform = platforms[index];
|
|
if (platform == null || platform.transform.position.y >= bottomLimit)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
highestPlatformY += RandomVerticalSpacing();
|
|
platforms[index] = RecyclePlatform(platform, new Vector2(RandomHorizontalPosition(highestPlatformY), highestPlatformY));
|
|
}
|
|
}
|
|
|
|
private void SpawnPlatform(Vector2 position, bool isStartPlatform)
|
|
{
|
|
var selectedPlatformPrefab = GetPlatformPrefab(position.y);
|
|
var platform = CreatePlatform(selectedPlatformPrefab, position, isStartPlatform);
|
|
if (platform == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
platforms.Add(platform);
|
|
InitializePlatform(platform, CreateContext(platform, isStartPlatform));
|
|
}
|
|
|
|
private PlatformController RecyclePlatform(PlatformController platform, Vector2 position)
|
|
{
|
|
var selectedPlatformPrefab = GetPlatformPrefab(position.y);
|
|
if (ShouldReplacePlatform(platform, selectedPlatformPrefab))
|
|
{
|
|
return ReplacePlatform(platform, selectedPlatformPrefab, position);
|
|
}
|
|
|
|
contentSpawner.ClearSpawnedContent(platform);
|
|
platform.gameObject.SetActive(true);
|
|
platform.transform.position = new Vector3(position.x, position.y, platform.transform.position.z);
|
|
RecyclePlatform(platform, CreateContext(platform, false));
|
|
return platform;
|
|
}
|
|
|
|
private PlatformController CreatePlatform(GameObject prefab, Vector2 position, bool isStartPlatform)
|
|
{
|
|
if (prefab == null)
|
|
{
|
|
Debug.LogError($"{nameof(PlatformSpawner)} cannot spawn a platform because no platform prefab is configured.", this);
|
|
return null;
|
|
}
|
|
|
|
var platformObject = container.InstantiatePrefab(prefab, new Vector3(position.x, position.y, 0f), Quaternion.identity, transform);
|
|
platformObject.name = isStartPlatform ? "Start Platform" : "Jump Platform";
|
|
platformObject.SetActive(true);
|
|
|
|
if (!platformObject.TryGetComponent(out PlatformController platform))
|
|
{
|
|
Debug.LogError($"{nameof(PlatformSpawner)} spawned platform without {nameof(PlatformController)}.", platformObject);
|
|
Destroy(platformObject);
|
|
return null;
|
|
}
|
|
|
|
prefabByPlatform[platform] = prefab;
|
|
return platform;
|
|
}
|
|
|
|
private PlatformController ReplacePlatform(PlatformController platform, GameObject selectedPlatformPrefab, Vector2 position)
|
|
{
|
|
contentSpawner.ClearSpawnedContent(platform);
|
|
prefabByPlatform.Remove(platform);
|
|
platform.gameObject.SetActive(false);
|
|
Destroy(platform.gameObject);
|
|
|
|
var replacement = CreatePlatform(selectedPlatformPrefab, position, false);
|
|
if (replacement == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
InitializePlatform(replacement, CreateContext(replacement, false));
|
|
return replacement;
|
|
}
|
|
|
|
private bool ShouldReplacePlatform(PlatformController platform, GameObject selectedPlatformPrefab)
|
|
{
|
|
if (platform == null || selectedPlatformPrefab == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return prefabByPlatform.TryGetValue(platform, out var currentPrefab) && currentPrefab != selectedPlatformPrefab;
|
|
}
|
|
|
|
private void InitializePlatform(PlatformController platform, PlatformSpawnContext context)
|
|
{
|
|
platform.Initialize(context);
|
|
contentSpawner.SpawnContent(platform, GetContentSource(context.WorldY), random);
|
|
}
|
|
|
|
private void RecyclePlatform(PlatformController platform, PlatformSpawnContext context)
|
|
{
|
|
platform.Recycle(context);
|
|
contentSpawner.SpawnContent(platform, GetContentSource(context.WorldY), random);
|
|
}
|
|
|
|
private PlatformSpawnContext CreateContext(PlatformController platform, bool isStartPlatform)
|
|
{
|
|
return new PlatformSpawnContext(container, playerController, gameplayCamera, nextSpawnIndex++, isStartPlatform, platform.transform.position.y);
|
|
}
|
|
|
|
private bool ValidateRuntimeState()
|
|
{
|
|
var isValid = true;
|
|
|
|
if (container == null)
|
|
{
|
|
Debug.LogError($"{nameof(PlatformSpawner)} was not injected. Check SceneContext and {nameof(GameplayInstaller)} setup.", this);
|
|
isValid = false;
|
|
}
|
|
|
|
if (playerController == null)
|
|
{
|
|
Debug.LogError($"{nameof(PlatformSpawner)} has no player reference.", this);
|
|
isValid = false;
|
|
}
|
|
|
|
if (gameplayCamera == null)
|
|
{
|
|
Debug.LogError($"{nameof(PlatformSpawner)} has no gameplay camera reference.", this);
|
|
isValid = false;
|
|
}
|
|
|
|
if (contentSpawner == null)
|
|
{
|
|
Debug.LogError($"{nameof(PlatformSpawner)} has no platform content spawner.", this);
|
|
isValid = false;
|
|
}
|
|
|
|
if (difficultyProgression == null)
|
|
{
|
|
Debug.LogError($"{nameof(PlatformSpawner)} has no difficulty progression reference.", this);
|
|
isValid = false;
|
|
}
|
|
else if (!difficultyProgression.HasStages)
|
|
{
|
|
Debug.LogError($"{nameof(PlatformSpawner)} has no difficulty stages configured.", this);
|
|
isValid = false;
|
|
}
|
|
|
|
return isValid;
|
|
}
|
|
|
|
private float RandomHorizontalPosition(float worldY)
|
|
{
|
|
var halfWidth = gameplayCamera != null ? gameplayCamera.orthographicSize * gameplayCamera.aspect : 3.75f;
|
|
var usableHalfWidth = Mathf.Max(0f, halfWidth - horizontalPadding);
|
|
if (usableHalfWidth <= 0f)
|
|
{
|
|
return 0f;
|
|
}
|
|
|
|
var distribution = difficultyProgression != null
|
|
? difficultyProgression.GetHorizontalDistribution(worldY)
|
|
: 0f;
|
|
|
|
var signed = RandomRange(-1f, 1f);
|
|
var sign = signed < 0f ? -1f : 1f;
|
|
var magnitude = Mathf.Abs(signed);
|
|
|
|
var normalizedMagnitude = distribution < 0f
|
|
? magnitude * (distribution + 1f)
|
|
: Mathf.Lerp(magnitude, 1f, distribution);
|
|
|
|
return sign * normalizedMagnitude * usableHalfWidth;
|
|
}
|
|
|
|
private float RandomRange(float min, float max)
|
|
{
|
|
return Mathf.Lerp(min, max, (float)random.NextDouble());
|
|
}
|
|
|
|
private static int CreateHardwareRandomSeed()
|
|
{
|
|
var unixTime = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
|
var seedSource = string.Concat(
|
|
unixTime,
|
|
"|",
|
|
SystemInfo.processorType,
|
|
"|",
|
|
SystemInfo.processorCount,
|
|
"|",
|
|
SystemInfo.graphicsDeviceName);
|
|
|
|
return StableHash(seedSource);
|
|
}
|
|
|
|
private static int StableHash(string value)
|
|
{
|
|
unchecked
|
|
{
|
|
const int offsetBasis = -2128831035;
|
|
const int prime = 16777619;
|
|
var hash = offsetBasis;
|
|
|
|
for (var index = 0; index < value.Length; index++)
|
|
{
|
|
hash ^= value[index];
|
|
hash *= prime;
|
|
}
|
|
|
|
return hash == int.MinValue ? int.MaxValue : Mathf.Abs(hash);
|
|
}
|
|
}
|
|
|
|
private float RandomVerticalSpacing()
|
|
{
|
|
if (difficultyProgression != null)
|
|
{
|
|
return difficultyProgression.GetVerticalSpacing(highestPlatformY);
|
|
}
|
|
|
|
return 0f;
|
|
}
|
|
|
|
private GameObject GetPlatformPrefab(float worldY)
|
|
{
|
|
return difficultyProgression != null
|
|
? difficultyProgression.GetPlatformPrefab(worldY, random)
|
|
: null;
|
|
}
|
|
|
|
private IPlatformContentSource GetContentSource(float worldY)
|
|
{
|
|
return difficultyProgression != null
|
|
? difficultyProgression.CreateContentSource(worldY)
|
|
: null;
|
|
}
|
|
|
|
private string GetStageName(float worldY)
|
|
{
|
|
return ProgressionForDiagnostics != null ? ProgressionForDiagnostics.GetStageName(worldY) : string.Empty;
|
|
}
|
|
}
|
|
}
|