using System.Collections.Generic; using System.Threading; using Cysharp.Threading.Tasks; using DanroJump.Audio; using DanroJump.Vfx; using UnityEngine; using Zenject; #if ENABLE_INPUT_SYSTEM using DanroJump.Hardware.Com.Input; using UnityEngine.InputSystem; #endif namespace DanroJump.Gameplay { /// /// Управляет игроком вертикального платформера: вводом, автопрыжками, wrap-переходом, смертью и анимациями. /// [RequireComponent(typeof(Rigidbody2D))] [RequireComponent(typeof(Collider2D))] public sealed class JumpPlayerController : MonoBehaviour, IPlayerController { [SerializeField] private float moveSpeed = 7.5f; [SerializeField] private float initialJumpVelocity = 15f; [SerializeField] private float autoJumpVelocity = 15f; [SerializeField] private float autoJumpCooldown = 0.08f; [SerializeField] [Range(0f, 1f)] private float groundContactNormalY = 0.45f; [SerializeField] private float groundContactTolerance = 0.12f; [SerializeField] private float groundProbeStartOffset = 0.08f; [SerializeField] private float groundProbeDistance = 0.34f; [SerializeField] private Vector2 equipmentProbeHalfExtents = new(0.5f, 0.35f); [SerializeField] private Vector2 sweptLandingProbeHalfExtents = new(0.48f, 0.08f); [SerializeField] private float sweptLandingExtraDistance = 0.18f; [SerializeField] private CollisionDetectionMode2D collisionDetectionMode = CollisionDetectionMode2D.Continuous; [SerializeField] private float horizontalWrapPadding = 0.65f; [SerializeField] private float fallResetDistance = 0.5f; [SerializeField] private bool fallResetFromCameraBottom = true; [SerializeField] [Range(0f, 1f)] private float respawnViewportY = 0.28f; [SerializeField] private bool centerRespawnX = true; [SerializeField] private float deathRespawnDelay = 0.35f; [SerializeField] private Animator animator; [SerializeField] private Transform visualRoot; [SerializeField] private string visualRootName = "Body"; [SerializeField] private Transform groundCheck; [SerializeField] private string groundCheckName = "GroundCheck"; [SerializeField] private float jumpAnimationPulseDuration = 0.12f; [SerializeField] private bool showWrappedVisualPreview = true; #if ENABLE_INPUT_SYSTEM [SerializeField] private InputActionReference moveActionReference; [SerializeField] private InputActionAsset inputActionsAsset; [SerializeField] private string moveActionPath = "Player/Move"; #endif private Rigidbody2D body; private Collider2D playerCollider; private Camera gameplayCamera; private IGameAudioService audioService; private IVfxService vfxService; private Vector3 startPosition; private Vector3 visualRootInitialScale; private Quaternion visualRootInitialRotation; private float horizontalInput; private float moveSpeedMultiplier = 1f; private float heightLiftTargetY; private float heightLiftSpeed; private float jumpAnimationPulseUntil; private float respawnAtTime = -1f; private float lastAutoJumpTime = -1f; private CancellationTokenSource moveSpeedBoostCancellation; private CancellationTokenSource heightLiftCancellation; private bool respawnBlocked; private bool enemyPassThroughWhileRising; private bool hasSpeedParameter; private bool hasVerticalSpeedParameter; private bool hasGroundedParameter; private bool hasJumpParameter; private bool hasDeathParameter; private AnimatorControllerParameterType deathParameterType; #if ENABLE_INPUT_SYSTEM private InputAction moveAction; private bool moveActionWasEnabled; #endif private Transform wrapVisualCloneRoot; private readonly List sourceRenderers = new(); private readonly List wrapRendererClones = new(); private readonly List landingHandlerBuffer = new(); private readonly List landingEquipmentBuffer = new(); private readonly List pickableBuffer = new(); private readonly List ignoredEnemyColliders = new(); private readonly RaycastHit2D[] groundProbeHits = new RaycastHit2D[8]; private readonly RaycastHit2D[] sweptGroundProbeHits = new RaycastHit2D[12]; private readonly RaycastHit2D[] sweptEquipmentProbeHits = new RaycastHit2D[12]; private readonly RaycastHit2D[] sweptPickableProbeHits = new RaycastHit2D[16]; private readonly Collider2D[] equipmentProbeHits = new Collider2D[8]; private readonly Collider2D[] pickableProbeHits = new Collider2D[16]; private readonly ContactFilter2D solidProbeFilter = new() { useTriggers = false }; private readonly ContactFilter2D triggerProbeFilter = new() { useTriggers = true }; private Bounds cachedVisualBounds; private int cachedVisualBoundsFrame = -1; private static readonly int SpeedHash = Animator.StringToHash("Speed"); private static readonly int VerticalSpeedHash = Animator.StringToHash("VSpeed"); private static readonly int GroundedHash = Animator.StringToHash("Grounded"); private static readonly int JumpHash = Animator.StringToHash("Jump"); private static readonly int DeathHash = Animator.StringToHash("Death"); private const int BaseAnimatorLayerIndex = 0; private const string JumpMidAirStateName = "Jump_Mid-Air"; private const string JumpMidAirStatePath = "Base Layer." + JumpMidAirStateName; private static readonly int JumpMidAirStateHash = Animator.StringToHash(JumpMidAirStateName); private static readonly int JumpMidAirStatePathHash = Animator.StringToHash(JumpMidAirStatePath); /// /// Максимальная высота текущего забега. /// public float HighestY { get; private set; } /// /// Текущая вертикальная скорость Rigidbody2D. /// public float VerticalVelocity => body != null ? body.linearVelocity.y : 0f; /// /// Позиция GroundCheck на предыдущем физическом шаге. /// public Vector2 PreviousGroundCheckPosition { get; private set; } /// /// Y-координата GroundCheck на предыдущем физическом шаге. /// public float PreviousGroundCheckY { get; private set; } /// /// Вертикальная скорость игрока на предыдущем физическом шаге. /// public float PreviousVerticalVelocity { get; private set; } /// /// Текущая мировая позиция GroundCheck. /// public Vector2 GroundCheckPosition => groundCheck != null ? groundCheck.position : transform.position; /// /// Текущая Y-координата GroundCheck. /// public float GroundCheckY => groundCheck != null ? groundCheck.position.y : transform.position.y; /// /// Базовая скорость автоматического прыжка игрока. /// public float AutoJumpVelocity => autoJumpVelocity; /// /// Задержка перед respawn после смерти игрока. /// public float DeathRespawnDelay => deathRespawnDelay; /// /// True, если игрок сейчас должен проходить сквозь врагов после усиленного подъема. /// public bool IgnoresEnemyContacts => enemyPassThroughWhileRising && VerticalVelocity > 0f && !IsDead; /// /// True, пока активен принудительный подъем от джетпака или другого lift-оборудования. /// public bool IsHeightLiftActive => heightLiftCancellation != null; /// /// True, пока активный lift должен отключать игровые контакты с окружением. /// public bool IgnoresEnvironmentInteractions => IsHeightLiftActive && !IsDead; /// /// Текущая целевая высота активного принудительного подъема. /// public float HeightLiftTargetY => heightLiftTargetY; /// /// Показывает, находится ли игрок в состоянии смерти/respawn. /// public bool IsDead { get; private set; } public event System.Action Died; public event System.Action Respawned; public event System.Action LandedOnNormalPlatform; public Transform Transform => transform; /// /// Получает gameplay-камеру через SceneContext. /// [Inject] public void Construct( Camera injectedGameplayCamera, [InjectOptional] IGameAudioService injectedAudioService = null, [InjectOptional] IVfxService injectedVfxService = null) { gameplayCamera = injectedGameplayCamera; audioService = injectedAudioService; vfxService = injectedVfxService; } /// /// Кэширует физику, визуальные ссылки и параметры Animator. /// private void Awake() { body = GetComponent(); playerCollider = GetComponent(); startPosition = transform.position; HighestY = transform.position.y; body.collisionDetectionMode = collisionDetectionMode; ResolveVisualReferences(); RebuildWrapVisuals(); CacheAnimatorParameters(); CachePhysicsStateForNextStep(); } /// /// Запускает первый прыжок сразу после старта сцены. /// private void Start() { Bounce(initialJumpVelocity); } private void OnEnable() { #if ENABLE_INPUT_SYSTEM RegisterMoveAction(); #endif } private void OnDisable() { #if ENABLE_INPUT_SYSTEM UnregisterMoveAction(); #endif } /// /// Обрабатывает ввод, wrap, смерть от падения и обновление Animator. /// private void Update() { if (IsDead) { TryFinishDeathRespawn(); UpdateAnimatorParameters(); return; } horizontalInput = ReadHorizontalInput(); HighestY = Mathf.Max(HighestY, transform.position.y); WrapAroundCameraBounds(); ResetWhenTooLow(); UpdateFacing(); UpdateAnimatorParameters(); } /// /// Двигает Rigidbody2D и проверяет автопрыжок в физическом шаге. /// private void FixedUpdate() { if (IsDead) { body.linearVelocity = Vector2.zero; EndEnemyPassThrough(); CachePhysicsStateForNextStep(); return; } body.linearVelocity = new Vector2(horizontalInput * moveSpeed * moveSpeedMultiplier, body.linearVelocity.y); if (!IgnoresEnvironmentInteractions) { TryAutoJumpFromGroundProbe(); TryPickUpOverlappingPickables(); } UpdateEnemyPassThroughState(); CachePhysicsStateForNextStep(); } /// /// Обновляет визуальный клон игрока на противоположной стороне экрана. /// private void LateUpdate() { UpdateWrappedVisualPreview(); } /// /// Уничтожает служебный визуальный клон при удалении игрока. /// private void OnDestroy() { EndEnemyPassThrough(); CancelMoveSpeedBoost(); CancelHeightLift(); #if ENABLE_INPUT_SYSTEM UnregisterMoveAction(); #endif if (wrapVisualCloneRoot != null) { Destroy(wrapVisualCloneRoot.gameObject); } } /// /// Временно изменяет горизонтальную скорость игрока. /// public void ApplyMoveSpeedMultiplier(float multiplier, float duration) { if (multiplier <= 0f || duration <= 0f) { return; } CancelMoveSpeedBoost(); moveSpeedBoostCancellation = new CancellationTokenSource(); ApplyMoveSpeedMultiplierAsync(multiplier, duration, moveSpeedBoostCancellation).Forget(Debug.LogException); } /// /// Поднимает игрока вверх с заданной скоростью на заданную высоту. /// public void ApplyHeightLift(float liftSpeed, float height) { if (liftSpeed <= 0f || height <= 0f) { return; } heightLiftSpeed = liftSpeed; jumpAnimationPulseUntil = -1f; PlayJumpMidAirAnimationIfNeeded(liftSpeed); if (heightLiftCancellation == null) { heightLiftTargetY = transform.position.y + height; heightLiftCancellation = new CancellationTokenSource(); ApplyHeightLiftAsync(heightLiftCancellation).Forget(Debug.LogException); } BeginEnemyPassThroughWhileRising(); } /// /// Проверяет автопрыжок при первом контакте с платформой. /// private void OnCollisionEnter2D(Collision2D collision) { if (IsDead) { return; } if (TryIgnoreEnemyCollision(collision)) { return; } if (IgnoresEnvironmentInteractions) { return; } TryAutoJump(collision); } /// /// Повторно проверяет автопрыжок, если игрок уже находится на платформе. /// private void OnCollisionStay2D(Collision2D collision) { if (IsDead) { return; } if (TryIgnoreEnemyCollision(collision)) { return; } if (IgnoresEnvironmentInteractions) { return; } TryAutoJump(collision); } /// /// Задает вертикальный импульс прыжка, не уменьшая уже существующую скорость вверх. /// public void Bounce(float velocity) { if (body == null) { body = GetComponent(); } if (body != null) { body.linearVelocity = new Vector2(body.linearVelocity.x, Mathf.Max(body.linearVelocity.y, velocity)); } jumpAnimationPulseUntil = Time.time + jumpAnimationPulseDuration; audioService?.Play(GameAudioEvent.PlayerJump, transform.position); vfxService?.SpawnJumpEffect(transform.position); } /// /// Задает усиленный вертикальный импульс и временно отключает столкновения с врагами на подъеме. /// public void BounceWithEnemyPassThrough(float velocity) { Bounce(velocity); BeginEnemyPassThroughWhileRising(); } /// /// Принудительно включает mid-air прыжок, если Animator еще не находится в этом состоянии. /// public void PlayJumpMidAirAnimationIfNeeded() { var verticalSpeed = body != null ? Mathf.Max(body.linearVelocity.y, heightLiftSpeed) : heightLiftSpeed; PlayJumpMidAirAnimationIfNeeded(verticalSpeed); } private void PlayJumpMidAirAnimationIfNeeded(float verticalSpeed) { if (animator == null || !animator.isActiveAndEnabled || animator.runtimeAnimatorController == null) { return; } var currentState = animator.GetCurrentAnimatorStateInfo(BaseAnimatorLayerIndex); if (!animator.IsInTransition(BaseAnimatorLayerIndex) && IsJumpMidAirState(currentState)) { return; } ApplyForcedAirborneAnimatorParameters(verticalSpeed); animator.Play(JumpMidAirStatePathHash, BaseAnimatorLayerIndex, 0f); animator.Update(0f); } private static bool IsJumpMidAirState(AnimatorStateInfo state) { return state.shortNameHash == JumpMidAirStateHash || state.fullPathHash == JumpMidAirStatePathHash; } private void ApplyForcedAirborneAnimatorParameters(float verticalSpeed) { if (hasVerticalSpeedParameter) { animator.SetFloat(VerticalSpeedHash, Mathf.Max(verticalSpeed, 0.01f)); } if (hasGroundedParameter) { animator.SetBool(GroundedHash, false); } if (hasJumpParameter) { animator.SetBool(JumpHash, false); } } /// /// Обновляет кэш визуальных renderer-ов после runtime-крепления или снятия экипировки. /// public void RefreshVisualRenderers() { cachedVisualBoundsFrame = -1; RebuildWrapVisuals(); } /// /// Отключает столкновение с указанным enemy-коллайдером, пока игрок поднимается после усиления. /// public bool TryIgnoreEnemyCollider(Collider2D enemyCollider) { if (!IgnoresEnemyContacts || enemyCollider == null || playerCollider == null) { return false; } var enemyRoot = enemyCollider.GetComponentInParent(); if (enemyRoot != null) { return TryIgnoreEnemy(enemyRoot); } IgnoreEnemyCollider(enemyCollider); return true; } /// /// Отключает столкновения со всеми коллайдерами врага, пока игрок поднимается после усиления. /// public bool TryIgnoreEnemy(EnemyBase enemy) { if (!IgnoresEnemyContacts || enemy == null || playerCollider == null) { return false; } var enemyColliders = enemy.GetComponentsInChildren(true); for (var index = 0; index < enemyColliders.Length; index++) { IgnoreEnemyCollider(enemyColliders[index]); } return true; } private void IgnoreEnemyCollider(Collider2D enemyCollider) { if (enemyCollider == null || enemyCollider == playerCollider) { return; } Physics2D.IgnoreCollision(playerCollider, enemyCollider, true); if (!ignoredEnemyColliders.Contains(enemyCollider)) { ignoredEnemyColliders.Add(enemyCollider); } } /// /// Отключает столкновение с врагом, если игрок поднимается после усиленного прыжка. /// private bool TryIgnoreEnemyCollision(Collision2D collision) { if (!IgnoresEnemyContacts || collision.collider == null) { return false; } var enemy = collision.collider.GetComponentInParent(); return enemy != null && TryIgnoreEnemy(enemy); } /// /// Убивает игрока внешним игровым объектом или системой. /// public void Kill() { Die(); } /// /// Полностью начинает новый забег со стартовой позиции. /// public void ResetRun() { ResetPlayerState(startPosition, true); } /// /// Блокирует отложенный respawn после окончательного GameOver. /// public void BlockPendingRespawn() { respawnBlocked = true; respawnAtTime = -1f; } /// /// Разрешает ручной respawn после подтвержденного продолжения игры. /// public void AllowRespawn() { respawnBlocked = false; } /// /// Разрешает отложенный respawn после того, как сессия подтвердила наличие жизней. /// public void ScheduleRespawn(float delay) { if (!IsDead || respawnBlocked) { return; } respawnAtTime = Time.time + Mathf.Max(0f, delay); } /// /// Возвращает игрока в поле зрения камеры без сброса прогресса текущей сессии. /// public void Respawn() { if (respawnBlocked) { return; } ResetPlayerState(GetRespawnPosition(), false); } /// /// Возвращает игрока к заданной позиции и сбрасывает временные эффекты. /// private void ResetPlayerState(Vector3 position, bool resetRunProgress) { IsDead = false; respawnBlocked = false; respawnAtTime = -1f; ClearDeathAnimationFlag(); transform.position = position; body.linearVelocity = Vector2.zero; HighestY = resetRunProgress ? transform.position.y : Mathf.Max(HighestY, transform.position.y); lastAutoJumpTime = -1f; ResetMoveSpeedMultiplier(); ResetHeightLift(); EndEnemyPassThrough(); CachePhysicsStateForNextStep(); Bounce(initialJumpVelocity); Respawned?.Invoke(this); } /// /// Animation Event для клипа прыжка. /// public void OnJump() { } /// /// Animation Event для шага или вторичных звуков движения. /// public void OnStep() { } /// /// Animation Event начала смерти. /// public void OnDeathBegin() { } /// /// Animation Event завершения смерти. Если игрок еще мертв, запускает respawn. /// public void OnDeathEnd() { if (IsDead && !respawnBlocked && respawnAtTime >= 0f && Time.time >= respawnAtTime) { Respawn(); } } /// /// Совместимый Animation Event для старых клипов, которые вызывают Jump. /// public void Jump() { OnJump(); } /// /// Совместимый Animation Event для старых клипов, которые вызывают Step. /// public void Step() { OnStep(); } private void TryAutoJump(Collision2D collision) { if (!CanAutoJumpNow()) { return; } if (!HasGroundContact(collision)) { return; } if (!IsValidLandingCollider(collision.collider)) { return; } var suppressAutoJump = TryHandlePlatformLanding(collision); // Оборудование платформы имеет приоритет над обычным автопрыжком. if (!TryActivateLandingEquipmentAtGround(Vector2.zero) && !TryActivateLandingEquipmentFromSweptProbe(Vector2.zero)) { if (!suppressAutoJump) { Bounce(autoJumpVelocity); } } lastAutoJumpTime = Time.time; } /// /// Проверяет, что collision действительно похож на приземление ногами, а не на удар боком или головой. /// private bool HasGroundContact(Collision2D collision) { for (var index = 0; index < collision.contactCount; index++) { var contact = collision.GetContact(index); if (Mathf.Abs(contact.normal.y) < groundContactNormalY) { continue; } // GroundCheck защищает от прыжка при контакте головой или боком. if (contact.point.y <= GroundCheckY + groundContactTolerance) { return true; } } return false; } /// /// Передает landing-событие платформенным обработчикам, если они есть на объекте столкновения. /// private bool TryHandlePlatformLanding(Collision2D collision) { var contactPoint = GroundCheckPosition; var contactNormal = Vector2.up; if (collision.contactCount > 0) { var contact = collision.GetContact(0); contactPoint = contact.point; contactNormal = contact.normal; } return TryHandlePlatformLanding( new PlatformLandingContext(collision.collider, collision, contactPoint, contactNormal, false)); } /// /// Передает landing-событие платформенным обработчикам, найденным по collider. /// private bool TryHandlePlatformLanding(PlatformLandingContext context) { var platformCollider = context.PlatformCollider; if (platformCollider == null) { return false; } var handled = false; platformCollider.GetComponentsInParent(false, landingHandlerBuffer); foreach (var landingHandler in landingHandlerBuffer) { handled |= landingHandler.TryHandleLanding(this, context); } landingHandlerBuffer.Clear(); return handled; } /// /// Применяет временный множитель скорости, затем возвращает нормальное значение. /// private async UniTask ApplyMoveSpeedMultiplierAsync( float multiplier, float duration, CancellationTokenSource cancellationSource) { try { moveSpeedMultiplier = multiplier; await UniTask.Delay( System.TimeSpan.FromSeconds(duration), DelayType.DeltaTime, PlayerLoopTiming.Update, cancellationSource.Token); } catch (System.OperationCanceledException) { } finally { if (moveSpeedBoostCancellation == cancellationSource) { moveSpeedBoostCancellation = null; moveSpeedMultiplier = 1f; cancellationSource.Dispose(); } } } /// /// Управляет подъемом игрока от lift-collectible без телепортации. /// private async UniTask ApplyHeightLiftAsync(CancellationTokenSource cancellationSource) { try { var token = cancellationSource.Token; lastAutoJumpTime = Time.time; while (!IsDead && transform.position.y < heightLiftTargetY && !token.IsCancellationRequested) { body.linearVelocity = new Vector2(body.linearVelocity.x, heightLiftSpeed); HighestY = Mathf.Max(HighestY, transform.position.y); await UniTask.Yield(PlayerLoopTiming.FixedUpdate, token); } if (!IsDead && !token.IsCancellationRequested) { body.linearVelocity = new Vector2(body.linearVelocity.x, Mathf.Max(body.linearVelocity.y, 0f)); } } catch (System.OperationCanceledException) { } finally { if (heightLiftCancellation == cancellationSource) { heightLiftCancellation = null; heightLiftSpeed = 0f; cancellationSource.Dispose(); } } } /// /// Сбрасывает временный множитель горизонтальной скорости. /// private void ResetMoveSpeedMultiplier() { CancelMoveSpeedBoost(); moveSpeedMultiplier = 1f; } /// /// Останавливает активный принудительный подъем игрока. /// private void ResetHeightLift() { CancelHeightLift(); heightLiftSpeed = 0f; } private void CancelMoveSpeedBoost() { if (moveSpeedBoostCancellation == null) { return; } var cancellation = moveSpeedBoostCancellation; moveSpeedBoostCancellation = null; cancellation.Cancel(); cancellation.Dispose(); } private void CancelHeightLift() { if (heightLiftCancellation == null) { return; } var cancellation = heightLiftCancellation; heightLiftCancellation = null; cancellation.Cancel(); cancellation.Dispose(); } /// /// Проверяет приземление через GroundCheck-пробы, включая ситуацию на другой стороне экрана при wrap. /// private void TryAutoJumpFromGroundProbe() { if (!CanAutoJumpNow()) { return; } if (TryResolveAutoJumpAtOffset(Vector2.zero, false)) { return; } if (TryGetWrapProbeOffset(out var wrapOffset)) { // Если игрок физически еще не перенесен, проверяем платформу на другой стороне экрана. TryResolveAutoJumpAtOffset(wrapOffset, true); } } /// /// Проверяет cooldown и направление движения перед автопрыжком. /// private bool CanAutoJumpNow() { return !IsDead && !IgnoresEnvironmentInteractions && body.linearVelocity.y <= 0.05f && Time.time - lastAutoJumpTime >= autoJumpCooldown; } /// /// Проверяет посадку обычным и swept-пробником, затем активирует JumpPad или автопрыжок. /// private bool TryResolveAutoJumpAtOffset(Vector2 probeOffset, bool commitWrapOnHit) { if (!CanAutoJumpNow()) { return false; } if (!TryGetGroundProbeHit(probeOffset, out var groundCollider) && !TryGetSweptGroundProbeHit(probeOffset, out groundCollider)) { return false; } if (commitWrapOnHit) { // Физическое тело переносится только когда найден реальный контакт после wrap. CommitWrapOffset(probeOffset); probeOffset = Vector2.zero; } var suppressAutoJump = TryHandlePlatformLanding( new PlatformLandingContext(groundCollider, null, GroundCheckPosition + probeOffset, Vector2.up, true)); if (!TryActivateLandingEquipmentAtGround(probeOffset) && !TryActivateLandingEquipmentFromSweptProbe(probeOffset)) { if (!suppressAutoJump) { Bounce(autoJumpVelocity); LandedOnNormalPlatform?.Invoke(); } } lastAutoJumpTime = Time.time; return true; } /// /// Выполняет короткий raycast вниз от GroundCheck и ищет валидную платформу под игроком. /// private bool TryGetGroundProbeHit(Vector2 probeOffset, out Collider2D hitCollider) { hitCollider = null; var origin = GroundCheckPosition + probeOffset + Vector2.up * groundProbeStartOffset; var hitCount = Physics2D.Raycast(origin, Vector2.down, solidProbeFilter, groundProbeHits, groundProbeStartOffset + groundProbeDistance); for (var index = 0; index < hitCount; index++) { if (IsValidGroundProbeHit(groundProbeHits[index], false)) { hitCollider = groundProbeHits[index].collider; return true; } } return false; } /// /// Выполняет swept-проверку между предыдущей и текущей позицией GroundCheck, чтобы не пролетать платформы. /// private bool TryGetSweptGroundProbeHit(Vector2 probeOffset, out Collider2D hitCollider) { hitCollider = null; if (!TryGetSweptLandingProbe(probeOffset, out var origin, out var direction, out var distance)) { return false; } var hitCount = Physics2D.BoxCast( origin, sweptLandingProbeHalfExtents * 2f, 0f, direction, solidProbeFilter, sweptGroundProbeHits, distance + sweptLandingExtraDistance); for (var index = 0; index < hitCount; index++) { if (IsValidGroundProbeHit(sweptGroundProbeHits[index], true)) { hitCollider = sweptGroundProbeHits[index].collider; return true; } } return false; } /// /// Отбрасывает неподходящие probe-попадания: самого игрока, trigger, верхние препятствия и закрытые one-way платформы. /// private bool IsValidGroundProbeHit(RaycastHit2D hit, bool isSweptProbe) { var hitCollider = hit.collider; if (hitCollider == null || hitCollider == playerCollider || hitCollider.isTrigger) { return false; } var allowedTopY = isSweptProbe ? Mathf.Max(PreviousGroundCheckY, GroundCheckY) + groundContactTolerance : GroundCheckY + groundContactTolerance; if (hit.point.y > allowedTopY) { return false; } if (hitCollider.TryGetComponent(out OneWayPlatformCollider oneWayPlatform)) { return oneWayPlatform.ShouldCollideWith(this); } return IsValidLandingCollider(hitCollider); } /// /// Разрешает автопрыжок только от платформенных коллайдеров, а не от произвольных solid-объектов. /// private static bool IsValidLandingCollider(Collider2D hitCollider) { if (hitCollider == null) { return false; } return hitCollider.TryGetComponent(out _) || hitCollider.TryGetComponent(out _); } /// /// Запоминает состояние GroundCheck и вертикальной скорости для следующего физического шага. /// private void CachePhysicsStateForNextStep() { PreviousGroundCheckPosition = GroundCheckPosition; PreviousGroundCheckY = GroundCheckY; PreviousVerticalVelocity = VerticalVelocity; } /// /// Ищет landing-оборудование рядом с GroundCheck и активирует его вместо обычного прыжка. /// private bool TryActivateLandingEquipmentAtGround(Vector2 probeOffset) { if (IgnoresEnvironmentInteractions) { return false; } var origin = GroundCheckPosition + probeOffset; var hitCount = Physics2D.OverlapBox( origin, equipmentProbeHalfExtents * 2f, 0f, triggerProbeFilter, equipmentProbeHits); for (var index = 0; index < hitCount; index++) { var hitCollider = equipmentProbeHits[index]; if (hitCollider == null || hitCollider == playerCollider || !hitCollider.isTrigger) { continue; } if (TryActivateLandingEquipment(hitCollider)) { return true; } } return false; } /// /// Ищет landing-оборудование в swept-зоне между предыдущей и текущей позицией GroundCheck. /// private bool TryActivateLandingEquipmentFromSweptProbe(Vector2 probeOffset) { if (IgnoresEnvironmentInteractions) { return false; } if (!TryGetSweptLandingProbe(probeOffset, out var origin, out var direction, out var distance)) { return false; } var hitCount = Physics2D.BoxCast( origin, equipmentProbeHalfExtents * 2f, 0f, direction, triggerProbeFilter, sweptEquipmentProbeHits, distance + sweptLandingExtraDistance); for (var index = 0; index < hitCount; index++) { var hitCollider = sweptEquipmentProbeHits[index].collider; if (hitCollider == null || hitCollider == playerCollider || !hitCollider.isTrigger) { continue; } if (TryActivateLandingEquipment(hitCollider)) { return true; } } return false; } private bool TryActivateLandingEquipment(Collider2D hitCollider) { if (IgnoresEnvironmentInteractions) { return false; } hitCollider.GetComponentsInParent(false, landingEquipmentBuffer); foreach (var landingEquipment in landingEquipmentBuffer) { if (landingEquipment.TryActivateFromLanding(this)) { landingEquipmentBuffer.Clear(); return true; } } landingEquipmentBuffer.Clear(); return false; } /// /// Подбирает IPickable-объекты, которые пересекаются с телом игрока, независимо от landing-пробы. /// private void TryPickUpOverlappingPickables() { if (IgnoresEnvironmentInteractions || playerCollider == null) { return; } var bounds = playerCollider.bounds; var hitCount = Physics2D.OverlapBox( bounds.center, bounds.size, 0f, triggerProbeFilter, pickableProbeHits); for (var index = 0; index < hitCount; index++) { var hitCollider = pickableProbeHits[index]; if (hitCollider == null || hitCollider == playerCollider || !hitCollider.isTrigger) { continue; } TryPickUpFromCollider(hitCollider); } TryPickUpSweptPickables(bounds); } private void TryPickUpSweptPickables(Bounds currentBounds) { if (IgnoresEnvironmentInteractions) { return; } var delta = GroundCheckPosition - PreviousGroundCheckPosition; var distance = delta.magnitude; if (distance <= 0.001f) { return; } var direction = delta / distance; var origin = (Vector2)currentBounds.center - delta; var hitCount = Physics2D.BoxCast( origin, (Vector2)currentBounds.size, 0f, direction, triggerProbeFilter, sweptPickableProbeHits, distance); for (var index = 0; index < hitCount; index++) { var hitCollider = sweptPickableProbeHits[index].collider; if (hitCollider == null || hitCollider == playerCollider || !hitCollider.isTrigger) { continue; } TryPickUpFromCollider(hitCollider); } } private bool TryPickUpFromCollider(Collider2D hitCollider) { if (IgnoresEnvironmentInteractions) { return false; } hitCollider.GetComponentsInParent(false, pickableBuffer); foreach (var pickable in pickableBuffer) { if (pickable.TryPickUp(this)) { pickableBuffer.Clear(); return true; } } pickableBuffer.Clear(); return false; } /// /// Вычисляет направление и дистанцию swept-пробы посадки между физическими шагами. /// private bool TryGetSweptLandingProbe(Vector2 probeOffset, out Vector2 origin, out Vector2 direction, out float distance) { origin = PreviousGroundCheckPosition + probeOffset; var currentPosition = GroundCheckPosition + probeOffset; var delta = currentPosition - origin; distance = delta.magnitude; direction = distance > 0f ? delta / distance : Vector2.down; return PreviousVerticalVelocity <= 0.05f && currentPosition.y < PreviousGroundCheckPosition.y && distance > 0.001f; } /// /// Переносит игрока на противоположную сторону камеры, когда весь визуал ушел за край. /// private void WrapAroundCameraBounds() { if (gameplayCamera == null) { return; } var cameraHalfWidth = gameplayCamera.orthographicSize * gameplayCamera.aspect; var leftEdge = gameplayCamera.transform.position.x - cameraHalfWidth; var rightEdge = gameplayCamera.transform.position.x + cameraHalfWidth; var screenWidth = rightEdge - leftEdge; var visualBounds = GetPlayerVisualBounds(); var wrapOffset = Vector2.zero; if (visualBounds.max.x < leftEdge - horizontalWrapPadding) { wrapOffset.x = screenWidth; } else if (visualBounds.min.x > rightEdge + horizontalWrapPadding) { wrapOffset.x = -screenWidth; } CommitWrapOffset(wrapOffset); } /// /// Возвращает временный offset для проверки посадки на противоположной стороне экрана. /// private bool TryGetWrapProbeOffset(out Vector2 wrapOffset) { wrapOffset = Vector2.zero; if (gameplayCamera == null) { return false; } var cameraHalfWidth = gameplayCamera.orthographicSize * gameplayCamera.aspect; var leftEdge = gameplayCamera.transform.position.x - cameraHalfWidth; var rightEdge = gameplayCamera.transform.position.x + cameraHalfWidth; var screenWidth = rightEdge - leftEdge; var visualBounds = GetPlayerVisualBounds(); if (visualBounds.min.x < leftEdge) { wrapOffset.x = screenWidth; } else if (visualBounds.max.x > rightEdge) { wrapOffset.x = -screenWidth; } return !Mathf.Approximately(wrapOffset.x, 0f); } /// /// Физически применяет wrap-offset к Rigidbody2D и синхронизирует Transform. /// private void CommitWrapOffset(Vector2 wrapOffset) { if (wrapOffset.sqrMagnitude <= 0f) { return; } var position = transform.position + (Vector3)wrapOffset; if (body != null) { body.position = new Vector2(position.x, position.y); transform.position = new Vector3(body.position.x, body.position.y, position.z); } else { transform.position = position; } Physics2D.SyncTransforms(); PreviousGroundCheckPosition += wrapOffset; } /// /// Запускает смерть, если игрок упал ниже допустимой линии камеры. /// private void ResetWhenTooLow() { if (gameplayCamera == null) { return; } var resetLineY = GetFallResetLineY(); if (GroundCheckY < resetLineY) { Die(); } } /// /// Переводит игрока в состояние смерти и запускает delayed respawn. /// private void Die() { if (IsDead) { return; } IsDead = true; respawnBlocked = false; respawnAtTime = -1f; if (body == null) { body = GetComponent(); } if (body != null) { body.linearVelocity = Vector2.zero; } ResetMoveSpeedMultiplier(); ResetHeightLift(); EndEnemyPassThrough(); PlayDeathAnimation(); vfxService?.SpawnPlayerDeathEffect(transform.position); Died?.Invoke(this); } /// /// Запускает режим прохождения сквозь врагов до начала падения. /// private void BeginEnemyPassThroughWhileRising() { enemyPassThroughWhileRising = true; } /// /// Выключает режим прохождения сквозь врагов, когда усиленный подъем закончился. /// private void UpdateEnemyPassThroughState() { if (enemyPassThroughWhileRising && (IsDead || VerticalVelocity <= 0f)) { EndEnemyPassThrough(); } } /// /// Возвращает физические столкновения игрока с ранее проигнорированными врагами. /// private void EndEnemyPassThrough() { enemyPassThroughWhileRising = false; if (playerCollider != null) { for (var index = ignoredEnemyColliders.Count - 1; index >= 0; index--) { var enemyCollider = ignoredEnemyColliders[index]; if (enemyCollider != null) { Physics2D.IgnoreCollision(playerCollider, enemyCollider, false); } } } ignoredEnemyColliders.Clear(); } /// /// Завершает задержку смерти и перезапускает забег. /// private void TryFinishDeathRespawn() { if (!respawnBlocked && respawnAtTime >= 0f && Time.time >= respawnAtTime) { Respawn(); } } /// /// Устанавливает параметр смерти в Animator, если он есть в текущем controller. /// private void PlayDeathAnimation() { if (!hasDeathParameter || animator == null) { return; } if (deathParameterType == AnimatorControllerParameterType.Trigger) { animator.SetTrigger(DeathHash); } else if (deathParameterType == AnimatorControllerParameterType.Bool) { animator.SetBool(DeathHash, true); } } /// /// Сбрасывает bool-параметр смерти после respawn. /// private void ClearDeathAnimationFlag() { if (hasDeathParameter && animator != null && deathParameterType == AnimatorControllerParameterType.Bool) { animator.SetBool(DeathHash, false); } } /// /// Возвращает Y-линию, ниже которой игрок считается упавшим. /// private float GetFallResetLineY() { if (fallResetFromCameraBottom) { return gameplayCamera.ViewportToWorldPoint(new Vector3(0.5f, 0f, 0f)).y - fallResetDistance; } return gameplayCamera.transform.position.y - fallResetDistance; } /// /// Вычисляет позицию respawn внутри текущего поля зрения камеры. /// private Vector3 GetRespawnPosition() { if (gameplayCamera == null) { return startPosition; } var cameraHalfHeight = gameplayCamera.orthographicSize; var cameraHalfWidth = cameraHalfHeight * gameplayCamera.aspect; var cameraPosition = gameplayCamera.transform.position; var position = transform.position; position.x = centerRespawnX ? cameraPosition.x : Mathf.Clamp(position.x, cameraPosition.x - cameraHalfWidth, cameraPosition.x + cameraHalfWidth); position.y = cameraPosition.y - cameraHalfHeight + cameraHalfHeight * 2f * respawnViewportY; return position; } /// /// Поворачивает визуальную часть игрока без изменения физического объекта. /// private void UpdateFacing() { if (Mathf.Abs(horizontalInput) < 0.01f) { return; } if (visualRoot == null) { return; } var scale = visualRootInitialScale; scale.y = Mathf.Abs(scale.y) * (Mathf.Sign(horizontalInput) < 0f ? -1f : 1f); visualRoot.localRotation = visualRootInitialRotation; visualRoot.localScale = scale; } /// /// Находит Animator, visualRoot и GroundCheck, если они не назначены в инспекторе. /// private void ResolveVisualReferences() { if (animator == null) { animator = GetComponent(); } if (visualRoot == null) { visualRoot = transform.Find(visualRootName); } if (visualRoot == null) { visualRoot = GetComponentInChildren()?.transform; } if (groundCheck == null) { groundCheck = transform.Find(groundCheckName); } if (visualRoot != null) { visualRootInitialScale = visualRoot.localScale; visualRootInitialRotation = visualRoot.localRotation; } } /// /// Создает набор SpriteRenderer-клонов для визуального предпросмотра wrap-перехода. /// private void RebuildWrapVisuals() { for (var index = wrapRendererClones.Count - 1; index >= 0; index--) { var clone = wrapRendererClones[index]; if (clone != null) { if (Application.isPlaying) { Destroy(clone.gameObject); } else { DestroyImmediate(clone.gameObject); } } } sourceRenderers.Clear(); wrapRendererClones.Clear(); if (visualRoot != null) { sourceRenderers.AddRange(visualRoot.GetComponentsInChildren(true)); } else { sourceRenderers.AddRange(GetComponentsInChildren(true)); } if (sourceRenderers.Count == 0) { return; } if (wrapVisualCloneRoot == null) { wrapVisualCloneRoot = new GameObject($"{name} Wrap Visuals").transform; wrapVisualCloneRoot.gameObject.SetActive(false); } for (var index = sourceRenderers.Count - 1; index >= 0; index--) { var sourceRenderer = sourceRenderers[index]; if (sourceRenderer == null) { sourceRenderers.RemoveAt(index); continue; } var clone = new GameObject($"{sourceRenderer.name} Wrap Clone").AddComponent(); clone.transform.SetParent(wrapVisualCloneRoot, false); wrapRendererClones.Insert(0, clone); } } /// /// Показывает копию визуала игрока на противоположном краю экрана перед фактическим wrap. /// private void UpdateWrappedVisualPreview() { if (!showWrappedVisualPreview || gameplayCamera == null || sourceRenderers.Count == 0) { SetWrappedVisualsActive(false); return; } var cameraHalfWidth = gameplayCamera.orthographicSize * gameplayCamera.aspect; var leftEdge = gameplayCamera.transform.position.x - cameraHalfWidth; var rightEdge = gameplayCamera.transform.position.x + cameraHalfWidth; var screenWidth = rightEdge - leftEdge; var visualBounds = GetPlayerVisualBounds(); var wrapOffsetX = 0f; if (visualBounds.min.x < leftEdge) { wrapOffsetX = screenWidth; } else if (visualBounds.max.x > rightEdge) { wrapOffsetX = -screenWidth; } if (Mathf.Approximately(wrapOffsetX, 0f)) { SetWrappedVisualsActive(false); return; } SetWrappedVisualsActive(true); CopyWrappedVisuals(new Vector3(wrapOffsetX, 0f, 0f)); } /// /// Копирует состояние исходных SpriteRenderer в wrap-клоны. /// private void CopyWrappedVisuals(Vector3 offset) { for (var index = 0; index < sourceRenderers.Count; index++) { var source = sourceRenderers[index]; var clone = index < wrapRendererClones.Count ? wrapRendererClones[index] : null; if (source == null || clone == null) { if (clone != null) { clone.gameObject.SetActive(false); } cachedVisualBoundsFrame = -1; continue; } try { clone.gameObject.SetActive(source.gameObject.activeInHierarchy); clone.enabled = source.enabled; clone.sprite = source.sprite; clone.color = source.color; clone.flipX = source.flipX; clone.flipY = source.flipY; clone.drawMode = source.drawMode; clone.size = source.size; clone.maskInteraction = source.maskInteraction; clone.sortingLayerID = source.sortingLayerID; clone.sortingOrder = source.sortingOrder; clone.sharedMaterial = source.sharedMaterial; clone.transform.SetPositionAndRotation(source.transform.position + offset, source.transform.rotation); clone.transform.localScale = source.transform.lossyScale; clone.gameObject.layer = source.gameObject.layer; } catch (MissingReferenceException) { sourceRenderers[index] = null; clone.gameObject.SetActive(false); cachedVisualBoundsFrame = -1; } } } /// /// Включает или выключает корень визуальных wrap-клонов. /// private void SetWrappedVisualsActive(bool isActive) { if (wrapVisualCloneRoot != null && wrapVisualCloneRoot.gameObject.activeSelf != isActive) { wrapVisualCloneRoot.gameObject.SetActive(isActive); } } /// /// Возвращает суммарные визуальные bounds игрока для wrap-логики. /// private Bounds GetPlayerVisualBounds() { if (cachedVisualBoundsFrame == Time.frameCount) { return cachedVisualBounds; } var hasBounds = false; var bounds = default(Bounds); for (var index = sourceRenderers.Count - 1; index >= 0; index--) { var sourceRenderer = sourceRenderers[index]; if (sourceRenderer == null) { sourceRenderers.RemoveAt(index); if (index < wrapRendererClones.Count) { var clone = wrapRendererClones[index]; if (clone != null) { if (Application.isPlaying) { Destroy(clone.gameObject); } else { DestroyImmediate(clone.gameObject); } } wrapRendererClones.RemoveAt(index); } continue; } try { if (!sourceRenderer.enabled || !sourceRenderer.gameObject.activeInHierarchy) { continue; } if (hasBounds) { bounds.Encapsulate(sourceRenderer.bounds); } else { bounds = sourceRenderer.bounds; hasBounds = true; } } catch (MissingReferenceException) { sourceRenderers.RemoveAt(index); if (index < wrapRendererClones.Count) { var clone = wrapRendererClones[index]; if (clone != null) { if (Application.isPlaying) { Destroy(clone.gameObject); } else { DestroyImmediate(clone.gameObject); } } wrapRendererClones.RemoveAt(index); } } } if (hasBounds) { cachedVisualBounds = bounds; cachedVisualBoundsFrame = Time.frameCount; return cachedVisualBounds; } cachedVisualBounds = playerCollider != null ? playerCollider.bounds : new Bounds(transform.position, Vector3.zero); cachedVisualBoundsFrame = Time.frameCount; return cachedVisualBounds; } /// /// Проверяет, какие параметры действительно есть в Animator Controller игрока. /// private void CacheAnimatorParameters() { if (animator == null) { return; } foreach (var parameter in animator.parameters) { if (parameter.nameHash == SpeedHash) { hasSpeedParameter = true; } else if (parameter.nameHash == VerticalSpeedHash) { hasVerticalSpeedParameter = true; } else if (parameter.nameHash == GroundedHash) { hasGroundedParameter = true; } else if (parameter.nameHash == JumpHash) { hasJumpParameter = true; } else if (parameter.nameHash == DeathHash) { hasDeathParameter = true; deathParameterType = parameter.type; } } } /// /// Передает скорость и одноразовый импульс прыжка в Animator. /// private void UpdateAnimatorParameters() { if (animator == null) { return; } var velocity = body.linearVelocity; if (hasSpeedParameter) { animator.SetFloat(SpeedHash, Mathf.Abs(velocity.x)); } if (hasVerticalSpeedParameter) { animator.SetFloat(VerticalSpeedHash, velocity.y); } if (hasGroundedParameter) { animator.SetBool(GroundedHash, false); } if (hasJumpParameter) { animator.SetBool(JumpHash, Time.time < jumpAnimationPulseUntil); } } /// /// Запускает death-триггер Animator, если он есть в текущем контроллере. /// private void TriggerDeathAnimation() { if (animator != null && hasDeathParameter) { animator.SetTrigger(DeathHash); } } /// /// Считывает горизонтальный ввод из Input System или legacy Input Manager. /// private float ReadHorizontalInput() { #if ENABLE_INPUT_SYSTEM RegisterMoveAction(); var actionInput = moveAction != null ? moveAction.ReadValue().x : 0f; if (Mathf.Abs(actionInput) > 0.001f) { return Mathf.Clamp(actionInput, -1f, 1f); } return ReadHorizontalInputFallback(); #elif ENABLE_LEGACY_INPUT_MANAGER return Input.GetAxisRaw("Horizontal"); #else return 0f; #endif } #if ENABLE_INPUT_SYSTEM private static float ReadHorizontalInputFallback() { var input = 0f; if (Keyboard.current != null) { if (Keyboard.current.leftArrowKey.isPressed || Keyboard.current.aKey.isPressed) { input -= 1f; } if (Keyboard.current.rightArrowKey.isPressed || Keyboard.current.dKey.isPressed) { input += 1f; } } if (Gamepad.current != null) { input += Gamepad.current.leftStick.x.ReadValue(); } if (ComControllerDevice.current != null) { if (ComControllerDevice.current.button02.isPressed) { input -= 1f; } if (ComControllerDevice.current.button03.isPressed) { input += 1f; } } return Mathf.Clamp(input, -1f, 1f); } private void RegisterMoveAction() { if (moveAction != null) { return; } moveAction = ResolveMoveAction(); if (moveAction == null) { return; } moveActionWasEnabled = moveAction.enabled; if (!moveAction.enabled) { moveAction.Enable(); } } private void UnregisterMoveAction() { if (moveAction == null) { return; } if (!moveActionWasEnabled && moveAction.enabled) { moveAction.Disable(); } moveAction = null; moveActionWasEnabled = false; } private InputAction ResolveMoveAction() { if (moveActionReference != null && moveActionReference.action != null) { return moveActionReference.action; } if (InputSystem.actions != null) { var projectMoveAction = InputSystem.actions.FindAction(moveActionPath, false); if (projectMoveAction != null) { return projectMoveAction; } } return inputActionsAsset != null ? inputActionsAsset.FindAction(moveActionPath, false) : null; } #endif } }