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 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)
{
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);
}
}
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;
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)
{
Destroy(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)
{
Destroy(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)
{
Destroy(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
}
}