Подробные изменения: - Добавлены сцены 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 из репозитория.
656 lines
20 KiB
C#
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
|
|
}
|
|
}
|
|
}
|