Danro-Jump/Assets/Scripts/Runtime/Gameplay/JumpPlayerController.cs
Danil Iarosh cefb2fd345 Build initial vertical platformer foundation
Подробные изменения:
- Добавлены сцены Bootstrap, MainMenu и Game с текущей вертикальной 9:16 структурой проекта.
- Добавлены runtime-скрипты gameplay-архитектуры: DI через Extenject, спавнер платформ, прогрессия этапов, one-way платформы, игрок, камера и оборудование JumpPad.
- Добавлены ScriptableObject-этапы сложности, weighted-настройки платформ, оборудования, противников и collectibles.
- Добавлены UI Toolkit ресурсы главного меню, preview-материалы, шрифты, спрайты, анимации и игровые prefab-ассеты.
- Перенесена и оформлена новая COM-система для работы с аппаратными портами и StreamingAssets.
- Обновлены проектные гайды, AGENTS.md, правила Git/Git LFS, .gitignore, Unity Packages и ProjectSettings.
- Удалён локальный skill start-required-mcp-servers из репозитория.
2026-05-11 07:41:14 +03:00

656 lines
20 KiB
C#

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<SpriteRenderer> sourceRenderers = new();
private readonly List<SpriteRenderer> 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<Rigidbody2D>();
playerCollider = GetComponent<Collider2D>();
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<JumpPlatform>(out _) || hitCollider.TryGetComponent<PlatformController>(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<Animator>();
}
if (visualRoot == null)
{
visualRoot = transform.Find(visualRootName);
}
if (visualRoot == null)
{
visualRoot = GetComponentInChildren<SpriteRenderer>()?.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<SpriteRenderer>(true));
}
else
{
sourceRenderers.AddRange(GetComponentsInChildren<SpriteRenderer>(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<SpriteRenderer>();
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
}
}
}