using System.Collections.Generic; using UnityEngine; using Zenject; #if ENABLE_INPUT_SYSTEM using UnityEngine.InputSystem; #endif namespace DanroJump.Gameplay { [RequireComponent(typeof(Rigidbody2D))] [RequireComponent(typeof(Collider2D))] public sealed class JumpPlayerController : MonoBehaviour { [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 float horizontalWrapPadding = 0.65f; [SerializeField] private float fallResetDistance = 5f; [SerializeField] private bool fallResetFromCameraBottom = true; [SerializeField] [Range(0f, 1f)] private float respawnViewportY = 0.28f; [SerializeField] private bool centerRespawnX = true; [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; private Rigidbody2D body; private Collider2D playerCollider; private Camera gameplayCamera; private Vector3 startPosition; private Vector3 visualRootInitialScale; private Quaternion visualRootInitialRotation; private float horizontalInput; private float jumpAnimationPulseUntil; private float lastAutoJumpTime = -1f; private bool hasSpeedParameter; private bool hasVerticalSpeedParameter; private bool hasGroundedParameter; private bool hasJumpParameter; private bool hasDeathParameter; private Transform wrapVisualCloneRoot; private readonly List sourceRenderers = new(); private readonly List wrapRendererClones = new(); private readonly RaycastHit2D[] groundProbeHits = new RaycastHit2D[8]; private readonly Collider2D[] equipmentProbeHits = new Collider2D[8]; 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"); public float HighestY { get; private set; } public float VerticalVelocity => body != null ? body.linearVelocity.y : 0f; public float GroundCheckY => groundCheck != null ? groundCheck.position.y : transform.position.y; public float AutoJumpVelocity => autoJumpVelocity; [Inject] public void Construct(Camera injectedGameplayCamera) { gameplayCamera = injectedGameplayCamera; } private void Awake() { body = GetComponent(); playerCollider = GetComponent(); startPosition = transform.position; HighestY = transform.position.y; ResolveVisualReferences(); RebuildWrapVisuals(); CacheAnimatorParameters(); } private void Start() { Bounce(initialJumpVelocity); } private void Update() { horizontalInput = ReadHorizontalInput(); HighestY = Mathf.Max(HighestY, transform.position.y); WrapAroundCameraBounds(); ResetWhenTooLow(); UpdateFacing(); UpdateAnimatorParameters(); } private void FixedUpdate() { body.linearVelocity = new Vector2(horizontalInput * moveSpeed, body.linearVelocity.y); TryAutoJumpFromGroundProbe(); } private void LateUpdate() { UpdateWrappedVisualPreview(); } private void OnDestroy() { if (wrapVisualCloneRoot != null) { Destroy(wrapVisualCloneRoot.gameObject); } } private void OnCollisionEnter2D(Collision2D collision) { TryAutoJump(collision); } private void OnCollisionStay2D(Collision2D collision) { TryAutoJump(collision); } public void Bounce(float velocity) { body.linearVelocity = new Vector2(body.linearVelocity.x, Mathf.Max(body.linearVelocity.y, velocity)); jumpAnimationPulseUntil = Time.time + jumpAnimationPulseDuration; } public void ResetRun() { transform.position = GetRespawnPosition(); body.linearVelocity = Vector2.zero; HighestY = Mathf.Max(HighestY, transform.position.y); lastAutoJumpTime = -1f; Bounce(initialJumpVelocity); } public void OnJump() { } public void OnStep() { } public void OnDeathBegin() { } public void OnDeathEnd() { ResetRun(); } public void Jump() { OnJump(); } public void Step() { OnStep(); } private void TryAutoJump(Collision2D collision) { if (!CanAutoJumpNow()) { return; } if (!HasGroundContact(collision)) { return; } if (!TryActivateJumpPadAtGround()) { Bounce(autoJumpVelocity); } lastAutoJumpTime = Time.time; } 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; } if (contact.point.y <= GroundCheckY + groundContactTolerance) { return true; } } return false; } private void TryAutoJumpFromGroundProbe() { if (!CanAutoJumpNow()) { return; } if (!HasGroundProbeHit()) { return; } if (!TryActivateJumpPadAtGround()) { Bounce(autoJumpVelocity); } lastAutoJumpTime = Time.time; } private bool CanAutoJumpNow() { return body.linearVelocity.y <= 0.05f && Time.time - lastAutoJumpTime >= autoJumpCooldown; } private bool HasGroundProbeHit() { var origin = (Vector2)(groundCheck != null ? groundCheck.position : transform.position) + Vector2.up * groundProbeStartOffset; var contactFilter = new ContactFilter2D { useTriggers = false }; var hitCount = Physics2D.Raycast(origin, Vector2.down, contactFilter, groundProbeHits, groundProbeStartOffset + groundProbeDistance); for (var index = 0; index < hitCount; index++) { if (IsValidGroundProbeHit(groundProbeHits[index])) { return true; } } return false; } private bool IsValidGroundProbeHit(RaycastHit2D hit) { var hitCollider = hit.collider; if (hitCollider == null || hitCollider == playerCollider || hitCollider.isTrigger) { return false; } if (hit.point.y > GroundCheckY + groundContactTolerance) { return false; } if (hitCollider.TryGetComponent(out OneWayPlatformCollider oneWayPlatform)) { return oneWayPlatform.ShouldCollideWith(this); } return hitCollider.TryGetComponent(out _) || hitCollider.TryGetComponent(out _); } private bool TryActivateJumpPadAtGround() { var origin = (Vector2)(groundCheck != null ? groundCheck.position : transform.position); var contactFilter = new ContactFilter2D { useTriggers = true }; var hitCount = Physics2D.OverlapBox( origin, equipmentProbeHalfExtents * 2f, 0f, contactFilter, equipmentProbeHits); for (var index = 0; index < hitCount; index++) { var hitCollider = equipmentProbeHits[index]; if (hitCollider == null || hitCollider == playerCollider || !hitCollider.isTrigger) { continue; } if (hitCollider.TryGetComponent(out JumpPad jumpPad) && jumpPad.TryActivate(this)) { return true; } } return false; } 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 position = transform.position; if (visualBounds.max.x < leftEdge - horizontalWrapPadding) { position.x += screenWidth; } else if (visualBounds.min.x > rightEdge + horizontalWrapPadding) { position.x -= screenWidth; } transform.position = position; } private void ResetWhenTooLow() { if (gameplayCamera == null) { return; } var resetLineY = GetFallResetLineY(); if (transform.position.y < resetLineY) { ResetRun(); } } private float GetFallResetLineY() { var cameraReferenceY = gameplayCamera.transform.position.y; if (fallResetFromCameraBottom) { cameraReferenceY -= gameplayCamera.orthographicSize; } return cameraReferenceY - fallResetDistance; } 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; } 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; } } private void RebuildWrapVisuals() { 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); } foreach (var sourceRenderer in sourceRenderers) { var clone = new GameObject($"{sourceRenderer.name} Wrap Clone").AddComponent(); clone.transform.SetParent(wrapVisualCloneRoot, false); wrapRendererClones.Add(clone); } } 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)); } private void CopyWrappedVisuals(Vector3 offset) { for (var index = 0; index < sourceRenderers.Count; index++) { var source = sourceRenderers[index]; var clone = wrapRendererClones[index]; 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; } } private void SetWrappedVisualsActive(bool isActive) { if (wrapVisualCloneRoot != null && wrapVisualCloneRoot.gameObject.activeSelf != isActive) { wrapVisualCloneRoot.gameObject.SetActive(isActive); } } private Bounds GetPlayerVisualBounds() { var hasBounds = false; var bounds = default(Bounds); foreach (var sourceRenderer in sourceRenderers) { if (!sourceRenderer.enabled || !sourceRenderer.gameObject.activeInHierarchy) { continue; } if (hasBounds) { bounds.Encapsulate(sourceRenderer.bounds); } else { bounds = sourceRenderer.bounds; hasBounds = true; } } if (hasBounds) { return bounds; } return playerCollider != null ? playerCollider.bounds : new Bounds(transform.position, Vector3.zero); } 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; } } } private void UpdateAnimatorParameters() { if (animator == null) { return; } if (hasSpeedParameter) { animator.SetFloat(SpeedHash, Mathf.Abs(body.linearVelocity.x)); } if (hasVerticalSpeedParameter) { animator.SetFloat(VerticalSpeedHash, body.linearVelocity.y); } if (hasGroundedParameter) { animator.SetBool(GroundedHash, false); } if (hasJumpParameter) { animator.SetBool(JumpHash, Time.time < jumpAnimationPulseUntil); } } private void TriggerDeathAnimation() { if (animator != null && hasDeathParameter) { animator.SetTrigger(DeathHash); } } private static float ReadHorizontalInput() { #if ENABLE_INPUT_SYSTEM 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(); } return Mathf.Clamp(input, -1f, 1f); #elif ENABLE_LEGACY_INPUT_MANAGER return Input.GetAxisRaw("Horizontal"); #else return 0f; #endif } } }