Last active
March 13, 2023 00:30
-
-
Save dresswithpockets/b441e09149634bee5fe66ffa5010f549 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#define DEBUG_CC2D_RAYS | |
using UnityEngine; | |
using System; | |
using System.Collections.Generic; | |
// ReSharper disable All | |
namespace Prime31 | |
{ | |
[RequireComponent(typeof(BoxCollider2D), typeof(Rigidbody2D))] | |
public sealed class CharacterController2D : MonoBehaviour | |
{ | |
#region internal types | |
private struct CharacterRaycastOrigins | |
{ | |
public Vector3 TopLeft; | |
public Vector3 BottomRight; | |
public Vector3 BottomLeft; | |
} | |
public sealed class CharacterCollisionState2D | |
{ | |
public bool Right; | |
public bool Left; | |
public bool Above; | |
public bool Below; | |
public bool BecameGroundedThisFrame; | |
public bool WasGroundedLastFrame; | |
public bool MovingDownSlope; | |
public float SlopeAngle; | |
public bool OnLeftEdge; | |
public bool OnRightEdge; | |
public bool HasCollision() | |
{ | |
return Below || Right || Left || Above; | |
} | |
public void Reset() | |
{ | |
Right = Left = Above = Below = BecameGroundedThisFrame = MovingDownSlope = false; | |
OnLeftEdge = false; | |
OnRightEdge = false; | |
SlopeAngle = 0f; | |
} | |
public override string ToString() | |
{ | |
return string.Format( | |
"[CharacterCollisionState2D] r: {0}, l: {1}, a: {2}, b: {3}, movingDownSlope: {4}, angle: {5}, wasGroundedLastFrame: {6}, becameGroundedThisFrame: {7}", | |
Right, Left, Above, Below, MovingDownSlope, SlopeAngle, WasGroundedLastFrame, | |
BecameGroundedThisFrame); | |
} | |
} | |
#endregion | |
#region events, properties and fields | |
public event Action<RaycastHit2D> OnControllerCollidedEvent; | |
public event Action<Collider2D> OnTriggerEnterEvent; | |
public event Action<Collider2D> OnTriggerStayEvent; | |
public event Action<Collider2D> OnTriggerExitEvent; | |
/// <summary> | |
/// when true, one way platforms will be ignored when moving vertically for a single frame | |
/// </summary> | |
public bool ignoreOneWayPlatformsThisFrame; | |
[SerializeField] [Range(0.001f, 0.3f)] private float _skinWidth = 0.02f; | |
/// <summary> | |
/// defines how far in from the edges of the collider rays are cast from. If cast with a 0 extent it will often result in ray hits that are | |
/// not desired (for example a foot collider casting horizontally from directly on the surface can result in a hit) | |
/// </summary> | |
public float skinWidth | |
{ | |
get => _skinWidth; | |
set | |
{ | |
_skinWidth = value; | |
RecalculateDistanceBetweenRays(); | |
} | |
} | |
/// <summary> | |
/// mask with all layers that the player should interact with | |
/// </summary> | |
public LayerMask platformMask = 0; | |
/// <summary> | |
/// mask with all layers that trigger events should fire when intersected | |
/// </summary> | |
public LayerMask triggerMask = 0; | |
/// <summary> | |
/// mask with all layers that should act as one-way platforms. Note that one-way platforms should always be EdgeCollider2Ds. This is because it does not support being | |
/// updated anytime outside of the inspector for now. | |
/// </summary> | |
[SerializeField] private LayerMask oneWayPlatformMask = 0; | |
/// <summary> | |
/// the max slope angle that the CC2D can climb | |
/// </summary> | |
/// <value>The slope limit.</value> | |
[Range(0f, 90f)] public float slopeLimit = 30f; | |
/// <summary> | |
/// the threshold in the change in vertical movement between frames that constitutes jumping | |
/// </summary> | |
/// <value>The jumping threshold.</value> | |
public float jumpingThreshold = 0.07f; | |
public bool jumpingThisFrame = false; | |
/// <summary> | |
/// curve for multiplying speed based on slope (negative = down slope and positive = up slope) | |
/// </summary> | |
public AnimationCurve slopeSpeedMultiplier = | |
new AnimationCurve(new Keyframe(-90f, 1.5f), new Keyframe(0f, 1f), new Keyframe(90f, 0f)); | |
[Range(2, 20)] public int totalHorizontalRays = 8; | |
[Range(2, 20)] public int totalVerticalRays = 4; | |
/// <summary> | |
/// this is used to calculate the downward ray that is cast to check for slopes. We use the somewhat arbitrary value 75 degrees | |
/// to calculate the length of the ray that checks for slopes. | |
/// </summary> | |
private float _slopeLimitTangent = Mathf.Tan(75f * Mathf.Deg2Rad); | |
[HideInInspector] [NonSerialized] public Transform Transform; | |
[HideInInspector] [NonSerialized] public BoxCollider2D BoxCollider; | |
[HideInInspector] [NonSerialized] public Rigidbody2D RigidBody2D; | |
[HideInInspector] [NonSerialized] | |
public CharacterCollisionState2D CollisionState = new(); | |
[HideInInspector] [NonSerialized] public Vector3 Velocity; | |
public bool IsGrounded | |
{ | |
get { return CollisionState.Below; } | |
} | |
private const float KSkinWidthFloatFudgeFactor = 0.001f; | |
#endregion | |
/// <summary> | |
/// holder for our raycast origin corners (TR, TL, BR, BL) | |
/// </summary> | |
private CharacterRaycastOrigins _raycastOrigins; | |
/// <summary> | |
/// stores our raycast hit during movement | |
/// </summary> | |
private RaycastHit2D _raycastHit; | |
/// <summary> | |
/// stores any raycast hits that occur this frame. we have to store them in case we get a hit moving | |
/// horizontally and vertically so that we can send the events after all collision state is set | |
/// </summary> | |
private readonly List<RaycastHit2D> _raycastHitsThisFrame = new(2); | |
// horizontal/vertical movement data | |
private float _verticalDistanceBetweenRays; | |
private float _horizontalDistanceBetweenRays; | |
// we use this flag to mark the case where we are travelling up a slope and we modified our delta.y to allow the climb to occur. | |
// the reason is so that if we reach the end of the slope we can make an adjustment to stay grounded | |
private bool _isGoingUpSlope = false; | |
#region Monobehaviour | |
private void Awake() | |
{ | |
// add our one-way platforms to our normal platform mask so that we can land on them from above | |
platformMask |= oneWayPlatformMask; | |
// cache some components | |
Transform = GetComponent<Transform>(); | |
BoxCollider = GetComponent<BoxCollider2D>(); | |
RigidBody2D = GetComponent<Rigidbody2D>(); | |
// here, we trigger our properties that have setters with bodies | |
skinWidth = _skinWidth; | |
// we want to set our CC2D to ignore all collision layers except what is in our triggerMask | |
for (var i = 0; i < 32; i++) | |
{ | |
// see if our triggerMask contains this layer and if not ignore it | |
if ((triggerMask.value & 1 << i) == 0) | |
Physics2D.IgnoreLayerCollision(gameObject.layer, i); | |
} | |
} | |
public void OnTriggerEnter2D(Collider2D col) | |
{ | |
if (OnTriggerEnterEvent != null) | |
OnTriggerEnterEvent(col); | |
} | |
public void OnTriggerStay2D(Collider2D col) | |
{ | |
if (OnTriggerStayEvent != null) | |
OnTriggerStayEvent(col); | |
} | |
public void OnTriggerExit2D(Collider2D col) | |
{ | |
if (OnTriggerExitEvent != null) | |
OnTriggerExitEvent(col); | |
} | |
#endregion | |
[System.Diagnostics.Conditional("DEBUG_CC2D_RAYS")] | |
private void DrawRay(Vector3 start, Vector3 dir, Color color) | |
{ | |
Debug.DrawRay(start, dir, color); | |
} | |
#region Public | |
/// <summary> | |
/// attempts to move the character to position + deltaMovement. Any colliders in the way will cause the movement to | |
/// stop when run into. | |
/// </summary> | |
/// <param name="deltaMovement">Delta movement.</param> | |
public void Move(Vector3 deltaMovement) | |
{ | |
// save off our current grounded state which we will use for wasGroundedLastFrame and becameGroundedThisFrame | |
CollisionState.WasGroundedLastFrame = CollisionState.Below; | |
// clear our state | |
CollisionState.Reset(); | |
_raycastHitsThisFrame.Clear(); | |
_isGoingUpSlope = false; | |
PrimeRaycastOrigins(); | |
// first, we check for a slope below us before moving | |
// only check slopes if we are going down and grounded | |
if (deltaMovement.y < 0f && CollisionState.WasGroundedLastFrame) | |
HandleVerticalSlope(ref deltaMovement); | |
// now we check movement in the horizontal dir | |
if (deltaMovement.x != 0f) | |
MoveHorizontally(ref deltaMovement); | |
// next, check movement in the vertical dir | |
if (deltaMovement.y != 0f) | |
MoveVertically(ref deltaMovement); | |
// move then update our state | |
deltaMovement.z = 0; | |
Transform.Translate(deltaMovement, Space.World); | |
// only calculate velocity if we have a non-zero deltaTime | |
if (Time.deltaTime > 0f) | |
Velocity = deltaMovement / Time.deltaTime; | |
// set our becameGrounded state based on the previous and current collision state | |
if (!CollisionState.WasGroundedLastFrame && CollisionState.Below) | |
CollisionState.BecameGroundedThisFrame = true; | |
// if we are going up a slope we artificially set a y velocity so we need to zero it out here | |
if (_isGoingUpSlope) | |
Velocity.y = 0; | |
// send off the collision events if we have a listener | |
if (OnControllerCollidedEvent != null) | |
{ | |
for (var i = 0; i < _raycastHitsThisFrame.Count; i++) | |
OnControllerCollidedEvent(_raycastHitsThisFrame[i]); | |
} | |
ignoreOneWayPlatformsThisFrame = false; | |
jumpingThisFrame = false; | |
} | |
/// <summary> | |
/// moves directly down until grounded | |
/// </summary> | |
public void WarpToGrounded() | |
{ | |
do | |
{ | |
Move(new Vector3(0, -1f, 0)); | |
} while (!IsGrounded); | |
} | |
/// <summary> | |
/// this should be called anytime you have to modify the BoxCollider2D at runtime. It will recalculate the distance between the rays used for collision detection. | |
/// It is also used in the skinWidth setter in case it is changed at runtime. | |
/// </summary> | |
public void RecalculateDistanceBetweenRays() | |
{ | |
// figure out the distance between our rays in both directions | |
// horizontal | |
var colliderUseableHeight = BoxCollider.size.y * Mathf.Abs(Transform.localScale.y) - (2f * _skinWidth); | |
_verticalDistanceBetweenRays = colliderUseableHeight / (totalHorizontalRays - 1); | |
// vertical | |
var colliderUseableWidth = BoxCollider.size.x * Mathf.Abs(Transform.localScale.x) - (2f * _skinWidth); | |
_horizontalDistanceBetweenRays = colliderUseableWidth / (totalVerticalRays - 1); | |
} | |
#endregion | |
#region Movement Methods | |
/// <summary> | |
/// resets the raycastOrigins to the current extents of the box collider inset by the skinWidth. It is inset | |
/// to avoid casting a ray from a position directly touching another collider which results in wonky normal data. | |
/// </summary> | |
private void PrimeRaycastOrigins() | |
{ | |
// our raycasts need to be fired from the bounds inset by the skinWidth | |
var modifiedBounds = BoxCollider.bounds; | |
modifiedBounds.Expand(-2f * _skinWidth); | |
_raycastOrigins.TopLeft = new Vector2(modifiedBounds.min.x, modifiedBounds.max.y); | |
_raycastOrigins.BottomRight = new Vector2(modifiedBounds.max.x, modifiedBounds.min.y); | |
_raycastOrigins.BottomLeft = modifiedBounds.min; | |
} | |
/// <summary> | |
/// we have to use a bit of trickery in this one. The rays must be cast from a small distance inside of our | |
/// collider (skinWidth) to avoid zero distance rays which will get the wrong normal. Because of this small offset | |
/// we have to increase the ray distance skinWidth then remember to remove skinWidth from deltaMovement before | |
/// actually moving the player | |
/// </summary> | |
private void MoveHorizontally(ref Vector3 deltaMovement) | |
{ | |
var isGoingRight = deltaMovement.x > 0; | |
var rayDistance = Mathf.Abs(deltaMovement.x) + _skinWidth; | |
var rayDirection = isGoingRight ? Vector2.right : -Vector2.right; | |
var initialRayOrigin = isGoingRight ? _raycastOrigins.BottomRight : _raycastOrigins.BottomLeft; | |
for (var i = 0; i < totalHorizontalRays; i++) | |
{ | |
var ray = new Vector2(initialRayOrigin.x, initialRayOrigin.y + i * _verticalDistanceBetweenRays); | |
DrawRay(ray, rayDirection * rayDistance, Color.red); | |
// if we are grounded we will include oneWayPlatforms only on the first ray (the bottom one). this will allow us to | |
// walk up sloped oneWayPlatforms | |
if (i == 0 && CollisionState.WasGroundedLastFrame) | |
_raycastHit = Physics2D.Raycast(ray, rayDirection, rayDistance, platformMask); | |
else | |
_raycastHit = Physics2D.Raycast(ray, rayDirection, rayDistance, platformMask & ~oneWayPlatformMask); | |
if (_raycastHit) | |
{ | |
// the bottom ray can hit a slope but no other ray can so we have special handling for these cases | |
if (i == 0 && HandleHorizontalSlope(ref deltaMovement, | |
Vector2.Angle(_raycastHit.normal, Vector2.up))) | |
{ | |
_raycastHitsThisFrame.Add(_raycastHit); | |
// if we weren't grounded last frame, that means we're landing on a slope horizontally. | |
// this ensures that we stay flush to that slope | |
if (!CollisionState.WasGroundedLastFrame) | |
{ | |
float flushDistance = Mathf.Sign(deltaMovement.x) * (_raycastHit.distance - skinWidth); | |
Transform.Translate(new Vector2(flushDistance, 0)); | |
} | |
break; | |
} | |
// set our new deltaMovement and recalculate the rayDistance taking it into account | |
deltaMovement.x = _raycastHit.point.x - ray.x; | |
rayDistance = Mathf.Abs(deltaMovement.x); | |
// remember to remove the skinWidth from our deltaMovement | |
if (isGoingRight) | |
{ | |
deltaMovement.x -= _skinWidth; | |
CollisionState.Right = true; | |
} | |
else | |
{ | |
deltaMovement.x += _skinWidth; | |
CollisionState.Left = true; | |
} | |
_raycastHitsThisFrame.Add(_raycastHit); | |
// we add a small fudge factor for the float operations here. if our rayDistance is smaller | |
// than the width + fudge bail out because we have a direct impact | |
if (rayDistance < _skinWidth + KSkinWidthFloatFudgeFactor) | |
break; | |
} | |
} | |
} | |
/// <summary> | |
/// handles adjusting deltaMovement if we are going up a slope. | |
/// </summary> | |
/// <returns><c>true</c>, if horizontal slope was handled, <c>false</c> otherwise.</returns> | |
/// <param name="deltaMovement">Delta movement.</param> | |
/// <param name="angle">Angle.</param> | |
private bool HandleHorizontalSlope(ref Vector3 deltaMovement, float angle) | |
{ | |
// disregard 90 degree angles (walls) | |
if (Mathf.RoundToInt(angle) == 90) | |
return false; | |
// if we can walk on slopes and our angle is small enough we need to move up | |
if (angle < slopeLimit) | |
{ | |
// we only need to adjust the deltaMovement if we are not jumping | |
// TODO: this uses a magic number which isn't ideal! The alternative is to have the user pass in if there is a jump this frame | |
if (!jumpingThisFrame) | |
{ | |
// apply the slopeModifier to slow our movement up the slope | |
var slopeModifier = slopeSpeedMultiplier.Evaluate(angle); | |
deltaMovement.x *= slopeModifier; | |
// we dont set collisions on the sides for this since a slope is not technically a side collision. | |
// smooth y movement when we climb. we make the y movement equivalent to the actual y location that corresponds | |
// to our new x location using our good friend Pythagoras | |
deltaMovement.y = Mathf.Abs(Mathf.Tan(angle * Mathf.Deg2Rad) * deltaMovement.x); | |
var isGoingRight = deltaMovement.x > 0; | |
// safety check. we fire a ray in the direction of movement just in case the diagonal we calculated above ends up | |
// going through a wall. if the ray hits, we back off the horizontal movement to stay in bounds. | |
var ray = isGoingRight ? _raycastOrigins.BottomRight : _raycastOrigins.BottomLeft; | |
RaycastHit2D raycastHit; | |
if (CollisionState.WasGroundedLastFrame) | |
raycastHit = Physics2D.Raycast(ray, deltaMovement.normalized, deltaMovement.magnitude, | |
platformMask); | |
else | |
raycastHit = Physics2D.Raycast(ray, deltaMovement.normalized, deltaMovement.magnitude, | |
platformMask & ~oneWayPlatformMask); | |
if (raycastHit) | |
{ | |
// we crossed an edge when using Pythagoras calculation, so we set the actual delta movement to the ray hit location | |
deltaMovement = (Vector3)raycastHit.point - ray; | |
if (isGoingRight) | |
deltaMovement.x -= _skinWidth; | |
else | |
deltaMovement.x += _skinWidth; | |
} | |
_isGoingUpSlope = true; | |
CollisionState.Below = true; | |
CollisionState.SlopeAngle = -angle; | |
} | |
} | |
else // too steep. get out of here | |
{ | |
deltaMovement.x = 0; | |
} | |
return true; | |
} | |
private void MoveVertically(ref Vector3 deltaMovement) | |
{ | |
var isGoingUp = deltaMovement.y > 0; | |
var rayDistance = Mathf.Abs(deltaMovement.y) + _skinWidth; | |
var rayDirection = isGoingUp ? Vector2.up : -Vector2.up; | |
var initialRayOrigin = isGoingUp ? _raycastOrigins.TopLeft : _raycastOrigins.BottomLeft; | |
// apply our horizontal deltaMovement here so that we do our raycast from the actual position we would be in if we had moved | |
initialRayOrigin.x += deltaMovement.x; | |
// if we are moving up, we should ignore the layers in oneWayPlatformMask | |
var mask = platformMask; | |
if ((isGoingUp && !CollisionState.WasGroundedLastFrame) || ignoreOneWayPlatformsThisFrame) | |
mask &= ~oneWayPlatformMask; | |
var brokenBeforeRightEdge = false; | |
for (var i = 0; i < totalVerticalRays; i++) | |
{ | |
var ray = new Vector2(initialRayOrigin.x + i * _horizontalDistanceBetweenRays, initialRayOrigin.y); | |
DrawRay(ray, rayDirection * rayDistance, Color.red); | |
_raycastHit = Physics2D.Raycast(ray, rayDirection, rayDistance, mask); | |
if (_raycastHit) | |
{ | |
// set our new deltaMovement and recalculate the rayDistance taking it into account | |
deltaMovement.y = _raycastHit.point.y - ray.y; | |
rayDistance = Mathf.Abs(deltaMovement.y); | |
// remember to remove the skinWidth from our deltaMovement | |
if (isGoingUp) | |
{ | |
deltaMovement.y -= _skinWidth; | |
CollisionState.Above = true; | |
} | |
else | |
{ | |
deltaMovement.y += _skinWidth; | |
CollisionState.Below = true; | |
} | |
_raycastHitsThisFrame.Add(_raycastHit); | |
// this is a hack to deal with the top of slopes. if we walk up a slope and reach the apex we can get in a situation | |
// where our ray gets a hit that is less then skinWidth causing us to be ungrounded the next frame due to residual velocity. | |
if (!isGoingUp && deltaMovement.y > 0.00001f) | |
_isGoingUpSlope = true; | |
// we add a small fudge factor for the float operations here. if our rayDistance is smaller | |
// than the width + fudge bail out because we have a direct impact | |
if (rayDistance < _skinWidth + KSkinWidthFloatFudgeFactor) | |
{ | |
brokenBeforeRightEdge = i != totalVerticalRays - 1; | |
break; | |
} | |
} | |
else | |
{ | |
if (i == 0) | |
CollisionState.OnLeftEdge = true; | |
else if (i == totalVerticalRays - 1) | |
CollisionState.OnRightEdge = true; | |
} | |
} | |
// since we broke out of the loop before we got to the right-edge ray, we need check the right edge for our | |
// collision state | |
if (brokenBeforeRightEdge) | |
{ | |
var i = totalVerticalRays - 1; | |
var ray = new Vector2(initialRayOrigin.x + i * _horizontalDistanceBetweenRays, initialRayOrigin.y); | |
DrawRay(ray, rayDirection * rayDistance, Color.red); | |
_raycastHit = Physics2D.Raycast(ray, rayDirection, rayDistance, mask); | |
if (!_raycastHit) | |
CollisionState.OnRightEdge = true; | |
} | |
} | |
/// <summary> | |
/// checks the center point under the BoxCollider2D for a slope. If it finds one then the deltaMovement is adjusted so that | |
/// the player stays grounded and the slopeSpeedModifier is taken into account to speed up movement. | |
/// </summary> | |
/// <param name="deltaMovement">Delta movement.</param> | |
private void HandleVerticalSlope(ref Vector3 deltaMovement) | |
{ | |
// slope check from the center of our collider | |
var centerOfCollider = (_raycastOrigins.BottomLeft.x + _raycastOrigins.BottomRight.x) * 0.5f; | |
var rayDirection = -Vector2.up; | |
// the ray distance is based on our slopeLimit | |
var slopeCheckRayDistance = _slopeLimitTangent * (_raycastOrigins.BottomRight.x - centerOfCollider); | |
var slopeRay = new Vector2(centerOfCollider, _raycastOrigins.BottomLeft.y); | |
DrawRay(slopeRay, rayDirection * slopeCheckRayDistance, Color.yellow); | |
_raycastHit = Physics2D.Raycast(slopeRay, rayDirection, slopeCheckRayDistance, platformMask); | |
if (_raycastHit) | |
{ | |
// bail out if we have no slope | |
var angle = Vector2.Angle(_raycastHit.normal, Vector2.up); | |
if (angle == 0) | |
return; | |
// we are moving down the slope if our normal and movement direction are in the same x direction | |
var isMovingDownSlope = Mathf.Sign(_raycastHit.normal.x) == Mathf.Sign(deltaMovement.x); | |
if (isMovingDownSlope) | |
{ | |
// going down we want to speed up in most cases so the slopeSpeedMultiplier curve should be > 1 for negative angles | |
var slopeModifier = slopeSpeedMultiplier.Evaluate(-angle); | |
// we add the extra downward movement here to ensure we "stick" to the surface below | |
deltaMovement.y += _raycastHit.point.y - slopeRay.y - skinWidth; | |
deltaMovement = new Vector3(0, deltaMovement.y, 0) + | |
(Quaternion.AngleAxis(-angle, Vector3.forward) * | |
new Vector3(deltaMovement.x * slopeModifier, 0, 0)); | |
CollisionState.MovingDownSlope = true; | |
CollisionState.SlopeAngle = angle; | |
} | |
} | |
} | |
#endregion | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Collections; | |
using System.Collections.Generic; | |
using System.Linq; | |
using DavidFDev.DevConsole; | |
using JetBrains.Annotations; | |
using Prime31; | |
using UnityEngine; | |
using UnityEngine.Events; | |
[RequireComponent(typeof(CharacterController2D))] | |
public sealed class PlayerController : MonoBehaviour | |
{ | |
[Header("Physics")] | |
public float gravity; | |
public float maxFallSpeed; | |
[Header("Interaction")] | |
public float interactableDistance; | |
[Header("Basic Movement")] | |
public float moveSpeed; | |
public float jumpSpeed; | |
public float jumpControlTime; | |
public float jumpCoyoteTime; | |
[Header("Dash")] | |
public float dashSpeed; | |
public float dashTime; | |
public float dashDelay; | |
[Header("Wall Cling")] | |
public float wallClingFallSpeed; | |
public float wallJumpOffTime; | |
public float wallJumpCoyoteTime; | |
[Header("Glide")] | |
public float glideDelay; | |
public float glideLevelHorizontalAccel; | |
public float glideLevelMaxHorizontalSpeed; | |
public float glideLevelMaxFallSpeed; | |
public float glideLevelGravity; | |
public float glideGroundedHorizontalDeceleration; | |
public float glideReleaseHorizontalDeceleration; | |
[Header("Slash Recoil")] | |
public float recoilHorizontalTime; | |
public float recoilHorizontalSpeed; | |
public float recoilUpTime; | |
public float recoilUpSpeed; | |
public float recoilDownSpeed; | |
public float recoilHorizontalLongSpeed; | |
public float recoilUpLongSpeed; | |
public float recoilDownLongSpeed; | |
[Header("Health")] | |
public int maxNormalHealth; | |
public float damageInvincibilityTime; | |
[Header("Combat")] | |
public int slashDamage; | |
public float slashRate; | |
public float horizontalSlashOffset; | |
public float verticalSlashOffset; | |
public GameObject slashPrefab; | |
[Header("Ground Slam")] | |
public float groundSlamSpeed; | |
public float groundSlamRecoveryTime; | |
public float groundSlamDelay; | |
public float groundSlamDamageMultiplier; | |
[Header("Greater Slash")] | |
public float greaterSlashTime; | |
public float greaterSlashForwardSpeed; | |
public float greaterSlashDelay; | |
public float greaterSlashDamageMultiplier; | |
public GameObject greaterSlashPrefab; | |
public FacingDirection Facing { get; private set; } | |
public FeatherType Feather { get; private set; } | |
public int Health { get; private set; } | |
public bool IsAlive => Health > 0; | |
private CharacterController2D _controller; | |
public bool HasInvulnerableFrames => state.GroundSlamming || state.GreaterSlashing; | |
public bool PostDamageInvincible => state.damageInvincibilityTimer > 0f; | |
private bool CanDash => GameManager.Instance.saveState.hasDash && | |
state.dashDelayTimer <= 0f && | |
!BlockAllActions; | |
private bool CanSlash => GameManager.Instance.saveState.hasSlash && | |
state.slashDelayTimer <= 0f && | |
!BlockAllActions; | |
private bool CanGlide => GameManager.Instance.saveState.hasGlide && | |
!_controller.IsGrounded && | |
state.glideDelayTimer <= 0f && | |
!state.Jumping && | |
state.walkOffPlatformTimer <= 0f && | |
state.clingOffTimer <= 0f && | |
!state.Clinging && | |
!state.WallJumping && | |
!BlockAllActions; | |
private bool BlockAllActions => | |
state.interact || | |
state.Dashing || | |
state.GroundSlamming || | |
state.GreaterSlashing || | |
state.inNest; | |
private bool CanJump => | |
!state.Jumping && (_controller.IsGrounded || state.walkOffPlatformTimer > 0f) && !BlockAllActions; | |
private bool CanWallJump => | |
GameManager.Instance.saveState.hasWallCling && !state.WallJumping && state.Clinging && !BlockAllActions; | |
private bool CanCoyoteWallJump => | |
GameManager.Instance.saveState.hasWallCling && | |
!state.WallJumping && | |
state.clingOffTimer > 0f && | |
!BlockAllActions; | |
private bool CanInteract => | |
state.currentInteractable != null && | |
_controller.IsGrounded && | |
!BlockAllActions; | |
private bool CanGroundSlam => | |
Feather == FeatherType.GroundSlam && | |
!_controller.IsGrounded && | |
state.groundSlamDelayTimer <= 0f && | |
!BlockAllActions; | |
private bool CanGreaterSlash => | |
Feather == FeatherType.GreaterSlash && | |
state.greaterSlashDelayTimer <= 0f && | |
!BlockAllActions; | |
public Vector2 CenterOffset => _controller.BoxCollider.offset; | |
public Vector2 RightOffset => CenterOffset + new Vector2(_controller.BoxCollider.size.x, 0f); | |
public Vector2 LeftOffset => CenterOffset - new Vector2(_controller.BoxCollider.size.x, 0f); | |
public readonly UnityEvent OnJumped = new(); | |
public readonly UnityEvent<FacingDirection> OnWallJumped = new(); | |
public readonly UnityEvent OnJumpReleased = new(); | |
public readonly UnityEvent<FacingDirection> OnDash = new(); | |
public readonly UnityEvent<GameObject, SlashDirection, bool> OnSlash = new(); | |
public readonly UnityEvent<PlayerDamageInfo> OnDamaged = new(); | |
public readonly UnityEvent<PlayerDamageInfo> OnDead = new(); | |
public readonly UnityEvent OnLand = new(); | |
public readonly UnityEvent<int> OnHealed = new(); | |
public readonly UnityEvent<FeatherType> OnFeatherPickup = new(); | |
private readonly Queue<(DamageDirection direction, bool longRecoil)> _recoilQueue = new(); | |
private BirdGameInputActions InputActions => GameManager.Instance.inputActions; | |
public PlayerState state = new(); | |
[HideInInspector] | |
public bool loading; | |
private bool _lockTimers; | |
private bool _inCinematic; | |
private bool _cannotTakeDamage; | |
private bool _cannotUpdate; | |
public sealed class PlayerState | |
{ | |
// vector for attack aiming, looking, and movement. | |
public Vector2 aim; | |
public Vector3 lastVelocity; | |
public Vector3 lastAcceleration; | |
public bool lastGrounded; | |
public bool lastClinging; | |
public CharacterController2D.CharacterCollisionState2D lastCollisionState; | |
public GameObject currentInteractable; | |
public bool interact; | |
public bool inNest; | |
public float damageInvincibilityTimer; | |
public float slashDelayTimer; | |
public bool slash; | |
public float dashTimer; | |
public float dashDelayTimer; | |
public FacingDirection dashDirection; | |
public bool Dashing => dashTimer > 0f; | |
public bool groundSlam; | |
public float groundSlamRecoveryTimer; | |
public float groundSlamDelayTimer; | |
public bool GroundSlamming => groundSlam || groundSlamRecoveryTimer > 0f; | |
public bool greaterSlashStart; | |
public float greaterSlashTimer; | |
public float greaterSlashDelayTimer; | |
public FacingDirection greaterSlashDirection; | |
public bool GreaterSlashing => greaterSlashTimer > 0f; | |
public bool glide; | |
public float glideDelayTimer; | |
public Vector2 glideMomentum; | |
// when non-zero, the player can release the jump early & does not have gravity applied | |
public float jumpControlTimer; | |
public bool jumpReleased; | |
public float walkOffPlatformTimer; | |
public bool Jumping => jumpControlTimer > 0; | |
public float clingOffTimer; | |
public FacingDirection clingOffDirection; | |
public float wallJumpOffTimer; | |
public FacingDirection wallJumpDirection; | |
public bool WallJumping => wallJumpOffTimer > 0f; | |
public float recoilLeftTimer; | |
public float recoilRightTimer; | |
public float recoilUpTimer; | |
public bool recoilDown; | |
public bool recoilLeftLong; | |
public bool recoilRightLong; | |
public bool recoilUpLong; | |
public bool recoilDownLong; | |
public bool AnyRecoil => recoilLeftTimer > 0f || recoilRightTimer > 0f || recoilUpTimer > 0f || recoilDown; | |
public bool Slashing => slash; | |
public bool NoHorizontalWishMovement => Mathf.Approximately(aim.x, 0f); | |
public bool Idle => lastGrounded && | |
NoHorizontalWishMovement && | |
lastVelocity.x == 0f && | |
!Slashing && | |
!Jumping && | |
!AnyRecoil; | |
public bool RunningOnGround => lastGrounded && !NoHorizontalWishMovement; | |
public bool Falling => lastVelocity.y <= 0f && !Jumping && !AnyRecoil && !lastGrounded && !Clinging; | |
public bool ClingingRight => GameManager.Instance.saveState.hasWallCling && | |
!lastGrounded && | |
!Dashing && | |
aim.x > 0f && | |
lastCollisionState.Right && | |
!glide; | |
public bool ClingingLeft => GameManager.Instance.saveState.hasWallCling && | |
!lastGrounded && | |
!Dashing && | |
aim.x < 0f && | |
lastCollisionState.Left && | |
!glide; | |
public bool Clinging => ClingingLeft || ClingingRight; | |
public FacingDirection ClingDirection => | |
ClingingLeft ? FacingDirection.Left : FacingDirection.Right; | |
public void Reset() | |
{ | |
// TODO: throw new NotImplementedException(); | |
} | |
} | |
private void Awake() | |
{ | |
_controller = GetComponent<CharacterController2D>(); | |
} | |
private void Start() | |
{ | |
Health = GameManager.Instance.saveState.maxBaseHealth; | |
} | |
public void BeginCinematic() | |
{ | |
_inCinematic = true; | |
GameManager.Instance.inputActions.gameplay.Disable(); | |
} | |
public void EndCinematic() | |
{ | |
_inCinematic = false; | |
GameManager.Instance.inputActions.gameplay.Enable(); | |
} | |
public void BeginNoDamage() | |
{ | |
_cannotTakeDamage = true; | |
} | |
public void EndNoDamage() | |
{ | |
_cannotTakeDamage = false; | |
} | |
public void BeginNoUpdate() | |
{ | |
_cannotUpdate = true; | |
} | |
public void EndNoUpdate() | |
{ | |
_cannotUpdate = false; | |
} | |
public void LockTimers() | |
{ | |
_lockTimers = true; | |
} | |
public void UnlockTimers() | |
{ | |
_lockTimers = false; | |
} | |
private void OnTriggerEnter2D(Collider2D other) | |
{ | |
if (other.CompareTag("Enemy")) | |
HandleEnemyCollision(other.gameObject); | |
} | |
private void OnTriggerStay2D(Collider2D other) | |
{ | |
if (other.CompareTag("Enemy")) | |
HandleEnemyCollision(other.gameObject); | |
} | |
private void OnCollisionEnter2D(Collision2D collision) | |
{ | |
if (collision.gameObject.CompareTag("Enemy")) | |
HandleEnemyCollision(collision.gameObject); | |
} | |
private void OnCollisionStay2D(Collision2D collision) | |
{ | |
if (collision.gameObject.CompareTag("Enemy")) | |
HandleEnemyCollision(collision.gameObject); | |
} | |
private void HandleEnemyCollision(GameObject enemy) | |
{ | |
var damageReceiver = enemy.GetComponent<IDamageReceiver>(); | |
if (damageReceiver == null) | |
return; | |
if (state.groundSlam) | |
{ | |
damageReceiver.Damage(DamageDirection.Down, | |
(int)(slashDamage * groundSlamDamageMultiplier), | |
DamageCategory.PlayerSpell, | |
false, | |
2f); | |
} | |
} | |
private void FixedUpdate() | |
{ | |
if (loading || _cannotUpdate) | |
return; | |
if (!_lockTimers) | |
UpdateStateTimers(Time.fixedDeltaTime); | |
// if we're in a cinematic, then control is given to a sequencer and not the player | |
if (!_inCinematic) | |
UpdateStateFromInput(); | |
if (state.interact) | |
Interact(); | |
if (state.slash) | |
Slash(); | |
if (state.greaterSlashStart) | |
GreaterSlash(); | |
UpdateRecoilStateFromQueue(); | |
var currentVelocity = _controller.Velocity; | |
UpdateHorizontalMovement(ref currentVelocity); | |
UpdateVerticalMovement(ref currentVelocity); | |
// recoils have precedence over other contributions to velocity - like gravity | |
UpdateRecoil(ref currentVelocity); | |
_controller.jumpingThisFrame = state.Jumping; | |
if (currentVelocity.x != 0 || currentVelocity.y != 0) | |
_controller.Move(currentVelocity * Time.fixedDeltaTime); | |
UpdateInteractables(); | |
UpdateStateEndOfFrame(); | |
} | |
private void UpdateStateTimers(float deltaTime) | |
{ | |
if (state.jumpControlTimer > 0f) | |
state.jumpControlTimer -= deltaTime; | |
if (state.slashDelayTimer > 0f) | |
state.slashDelayTimer -= deltaTime; | |
if (state.damageInvincibilityTimer > 0f) | |
state.damageInvincibilityTimer -= deltaTime; | |
if (state.recoilRightTimer > 0f) | |
state.recoilRightTimer -= deltaTime; | |
if (state.recoilLeftTimer > 0f) | |
state.recoilLeftTimer -= deltaTime; | |
if (state.recoilUpTimer > 0f) | |
state.recoilUpTimer -= deltaTime; | |
if (state.dashTimer > 0f) | |
state.dashTimer -= deltaTime; | |
if (state.dashDelayTimer > 0f) | |
state.dashDelayTimer -= deltaTime; | |
if (state.wallJumpOffTimer > 0f) | |
{ | |
state.wallJumpOffTimer -= deltaTime; | |
if (state.wallJumpOffTimer <= 0f) | |
// the post-jumpOff jump should result in a jump that's the same height as a normal jump, | |
// so we subtract wallJumpOffTime from the normal jumpControlTime for the same result | |
state.jumpControlTimer = jumpControlTime - wallJumpOffTime; | |
} | |
if (state.glideDelayTimer > 0f) | |
state.glideDelayTimer -= deltaTime; | |
if (state.walkOffPlatformTimer > 0f) | |
state.walkOffPlatformTimer -= deltaTime; | |
if (state.clingOffTimer > 0f) | |
state.clingOffTimer -= deltaTime; | |
if (state.groundSlamRecoveryTimer > 0f) | |
state.groundSlamRecoveryTimer -= deltaTime; | |
if (state.groundSlamDelayTimer > 0f) | |
state.groundSlamDelayTimer -= deltaTime; | |
if (state.greaterSlashTimer > 0f) | |
state.greaterSlashTimer -= deltaTime; | |
if (state.greaterSlashDelayTimer > 0f) | |
state.greaterSlashDelayTimer -= deltaTime; | |
} | |
private void UpdateStateFromInput() | |
{ | |
state.aim = Vector2.zero; | |
if (!IsAlive) | |
return; | |
var horizontal = InputActions.gameplay.horizontal.ReadValue<float>(); | |
var vertical = InputActions.gameplay.vertical.ReadValue<float>(); | |
state.aim = new Vector2(horizontal, vertical); | |
if (CanInteract && InputActions.gameplay.interact.WasPressedThisFrame()) | |
{ | |
state.interact = true; | |
} | |
if (CanJump && InputActions.gameplay.jump.WasPressedThisFrame()) | |
{ | |
state.jumpControlTimer = jumpControlTime; | |
OnJumped.Invoke(); | |
} | |
if (state.Jumping && !InputActions.gameplay.jump.IsPressed()) | |
{ | |
state.jumpControlTimer = 0f; | |
state.jumpReleased = true; | |
OnJumpReleased.Invoke(); | |
} | |
if (CanWallJump && InputActions.gameplay.jump.WasPressedThisFrame()) | |
{ | |
state.wallJumpOffTimer = wallJumpOffTime; | |
state.wallJumpDirection = state.ClingDirection == FacingDirection.Right | |
? FacingDirection.Left | |
: FacingDirection.Right; | |
OnWallJumped.Invoke(state.wallJumpDirection); | |
} | |
if (CanCoyoteWallJump && InputActions.gameplay.jump.WasPressedThisFrame()) | |
{ | |
state.wallJumpOffTimer = wallJumpOffTime; | |
state.wallJumpDirection = state.clingOffDirection; | |
state.clingOffTimer = 0f; | |
OnWallJumped.Invoke(state.wallJumpDirection); | |
} | |
if (CanDash && InputActions.gameplay.dash.WasPressedThisFrame()) | |
{ | |
var clinging = state.Clinging; | |
state.jumpControlTimer = 0f; | |
state.jumpReleased = false; | |
state.dashTimer = dashTime; | |
state.dashDelayTimer = dashTime + dashDelay; | |
state.wallJumpOffTimer = 0f; | |
if (state.aim.x == 0f) | |
state.dashDirection = Facing; | |
else | |
{ | |
if (clinging) | |
state.dashDirection = state.ClingDirection == FacingDirection.Right | |
? FacingDirection.Left | |
: FacingDirection.Right; | |
else | |
state.dashDirection = state.aim.x > 0 ? FacingDirection.Right : FacingDirection.Left; | |
} | |
OnDash.Invoke(state.dashDirection); | |
} | |
if (CanSlash && InputActions.gameplay.attack.WasPressedThisFrame()) | |
{ | |
state.slashDelayTimer = 1f / slashRate; | |
state.slash = true; | |
} | |
if (CanGroundSlam && InputActions.gameplay.cast.WasPressedThisFrame() && state.aim is { x: 0, y: < 0 }) | |
{ | |
state.groundSlam = true; | |
Feather = FeatherType.None; | |
} | |
if (CanGreaterSlash && InputActions.gameplay.cast.WasPressedThisFrame() && state.aim is { x: not 0, y: 0 }) | |
{ | |
state.greaterSlashStart = true; | |
state.greaterSlashTimer = greaterSlashTime; | |
state.greaterSlashDelayTimer = greaterSlashTime + greaterSlashDelay; | |
state.greaterSlashDirection = state.aim.x > 0 ? FacingDirection.Right : FacingDirection.Left; | |
Feather = FeatherType.None; | |
} | |
if (CanGlide && InputActions.gameplay.jump.WasPressedThisFrame()) | |
{ | |
state.glide = true; | |
} | |
if (state.glide && (!InputActions.gameplay.jump.IsPressed() || !CanGlide)) | |
{ | |
state.glide = false; | |
state.glideDelayTimer = glideDelay; | |
} | |
} | |
private void UpdateHorizontalMovement(ref Vector3 velocity) | |
{ | |
if (state.glide) | |
{ | |
if (state.NoHorizontalWishMovement) | |
return; | |
velocity.x += Mathf.Sign(state.aim.x) * glideLevelHorizontalAccel * Time.fixedDeltaTime; | |
velocity.x = Mathf.Clamp(velocity.x, -glideLevelMaxHorizontalSpeed, glideLevelMaxHorizontalSpeed); | |
state.glideMomentum.x = velocity.x; | |
Facing = velocity.x > 0 ? FacingDirection.Right : FacingDirection.Left; | |
return; | |
} | |
if (state.glideMomentum.x > 0f && (state.aim.x <= 0f || _controller.IsGrounded)) | |
{ | |
var decel = state.aim.x <= 0f ? glideReleaseHorizontalDeceleration : glideGroundedHorizontalDeceleration; | |
state.glideMomentum.x -= decel * Time.fixedDeltaTime; | |
if (state.glideMomentum.x < 0f) | |
state.glideMomentum.x = 0f; | |
} | |
else if (state.glideMomentum.x < 0f && (state.aim.x >= 0f || _controller.IsGrounded)) | |
{ | |
var decel = state.aim.x >= 0f ? glideReleaseHorizontalDeceleration : glideGroundedHorizontalDeceleration; | |
state.glideMomentum.x += decel * Time.fixedDeltaTime; | |
if (state.glideMomentum.x > 0f) | |
state.glideMomentum.x = 0f; | |
} | |
if (state.WallJumping) | |
{ | |
velocity.x = state.wallJumpDirection == FacingDirection.Right ? moveSpeed : -moveSpeed; | |
Facing = state.wallJumpDirection; | |
return; | |
} | |
if (state.Dashing) | |
{ | |
velocity.x = state.dashDirection == FacingDirection.Right ? dashSpeed : -dashSpeed; | |
Facing = state.dashDirection; | |
return; | |
} | |
if (state.GroundSlamming) | |
{ | |
velocity.x = 0f; | |
return; | |
} | |
if (state.GreaterSlashing) | |
{ | |
velocity.x = state.greaterSlashDirection == FacingDirection.Left | |
? -greaterSlashForwardSpeed | |
: greaterSlashForwardSpeed; | |
Facing = state.greaterSlashDirection; | |
return; | |
} | |
if (state.Clinging) | |
{ | |
velocity.x = state.ClingDirection == FacingDirection.Right ? 1f : -1f; | |
Facing = state.ClingDirection; | |
return; | |
} | |
// if the player isn't adding any horizontal movement, then we decelerate towards zero horizontal speed | |
if (state.NoHorizontalWishMovement) | |
{ | |
velocity.x = state.glideMomentum.x; | |
return; | |
} | |
var speed = moveSpeed; | |
// otherwise, we accelerate towards their desired speed: | |
if (state.aim.x > 0f) | |
velocity.x = Mathf.Max(speed, state.glideMomentum.x); | |
else if (state.aim.x < 0f) | |
velocity.x = Mathf.Min(-speed, state.glideMomentum.x); | |
Facing = velocity.x > 0 ? FacingDirection.Right : FacingDirection.Left; | |
} | |
private void UpdateVerticalMovement(ref Vector3 velocity) | |
{ | |
if (state.WallJumping) | |
{ | |
velocity.y = jumpSpeed; | |
return; | |
} | |
if (state.Dashing) | |
{ | |
velocity.y = 0f; | |
return; | |
} | |
if (state.GroundSlamming) | |
{ | |
velocity.y = -groundSlamSpeed; | |
return; | |
} | |
if (state.GreaterSlashing) | |
{ | |
velocity.y = 0; | |
return; | |
} | |
if (state.Clinging && velocity.y < 0f) | |
{ | |
velocity.y = -wallClingFallSpeed; | |
return; | |
} | |
if (_controller.IsGrounded) | |
{ | |
if (state.Jumping) | |
velocity.y = jumpSpeed; | |
else | |
velocity.y = -1f; | |
return; | |
} | |
if (state.jumpReleased) | |
velocity.y = 0f; | |
if (state.Jumping) | |
velocity.y = jumpSpeed; | |
else | |
{ | |
var fallSpeed = state.glide ? glideLevelMaxFallSpeed : maxFallSpeed; | |
var fallGravity = state.glide ? glideLevelGravity : gravity; | |
velocity.y = Mathf.Max(-fallSpeed, velocity.y - fallGravity * Time.fixedDeltaTime); | |
} | |
} | |
private void UpdateRecoilStateFromQueue() | |
{ | |
while (_recoilQueue.Count > 0) | |
{ | |
var recoil = _recoilQueue.Dequeue(); | |
switch (recoil.direction) | |
{ | |
case DamageDirection.Right: | |
state.recoilLeftTimer = recoilHorizontalTime; | |
state.recoilLeftLong = recoil.longRecoil; | |
break; | |
case DamageDirection.Left: | |
state.recoilRightTimer = recoilHorizontalTime; | |
state.recoilRightLong = recoil.longRecoil; | |
break; | |
case DamageDirection.Up: | |
state.recoilDown = true; | |
state.recoilDownLong = recoil.longRecoil; | |
break; | |
case DamageDirection.Down: | |
state.recoilUpTimer = recoilUpTime; | |
state.recoilUpLong = recoil.longRecoil; | |
break; | |
} | |
} | |
} | |
private void UpdateRecoil(ref Vector3 velocity) | |
{ | |
if (state.recoilUpTimer > 0f || state.recoilDown) | |
{ | |
velocity.y = 0f; | |
if (state.recoilUpTimer > 0f) | |
velocity.y += state.recoilUpLong ? recoilUpLongSpeed : recoilUpSpeed; | |
if (state.recoilDown) | |
velocity.y -= state.recoilDownLong ? recoilDownLongSpeed : recoilDownSpeed; | |
} | |
if (state.recoilLeftTimer > 0f || state.recoilRightTimer > 0f) | |
{ | |
velocity.x = 0f; | |
if (state.recoilRightTimer > 0f) | |
velocity.x += state.recoilRightLong ? recoilHorizontalLongSpeed : recoilHorizontalSpeed; | |
if (state.recoilLeftTimer > 0f) | |
velocity.x -= state.recoilLeftLong ? recoilHorizontalLongSpeed : recoilHorizontalSpeed; | |
} | |
} | |
private void UpdateInteractables() | |
{ | |
var interactables = GameObject.FindGameObjectsWithTag("Interactable"); | |
var nearestEligible = interactables | |
.Select(i => (gameObject: i, sqrDistance: (i.transform.position - transform.position).sqrMagnitude)) | |
.OrderBy(i => i.sqrDistance) | |
.FirstOrDefault(i => i.sqrDistance <= interactableDistance * interactableDistance); | |
state.currentInteractable = nearestEligible.gameObject; | |
} | |
private void UpdateStateEndOfFrame() | |
{ | |
if (!state.Clinging && state.lastClinging && !state.WallJumping) | |
{ | |
state.clingOffTimer = wallJumpCoyoteTime; | |
state.clingOffDirection = state.ClingDirection; | |
} | |
if (state.groundSlam && _controller.IsGrounded) | |
{ | |
state.groundSlam = false; | |
state.groundSlamRecoveryTimer = groundSlamRecoveryTime; | |
state.groundSlamDelayTimer = groundSlamRecoveryTime + groundSlamDelay; | |
} | |
if (_controller.IsGrounded && !state.lastGrounded) | |
OnLand.Invoke(); | |
if (!_controller.IsGrounded && state.lastGrounded) | |
{ | |
if (!state.Jumping && _controller.Velocity.y <= 0f && _controller.Velocity.x != 0f) | |
state.walkOffPlatformTimer = jumpCoyoteTime; | |
} | |
state.jumpReleased = state.Jumping && _controller.CollisionState.Above; | |
if (state.jumpReleased) | |
state.jumpControlTimer = 0f; | |
state.slash = false; | |
state.greaterSlashStart = false; | |
state.recoilDown = false; | |
state.recoilDownLong = false; | |
state.interact = false; | |
if (state.recoilRightTimer <= 0f || _controller.CollisionState.Left) | |
{ | |
state.recoilRightLong = false; | |
state.recoilRightTimer = 0f; | |
} | |
if (state.recoilLeftTimer <= 0f || _controller.CollisionState.Right) | |
{ | |
state.recoilLeftLong = false; | |
state.recoilLeftTimer = 0f; | |
} | |
if (state.recoilUpTimer <= 0f || _controller.CollisionState.Above) | |
{ | |
state.recoilUpLong = false; | |
state.recoilUpTimer = 0f; | |
} | |
if (_controller.IsGrounded) | |
state.dashDelayTimer = 0f; | |
state.lastClinging = state.Clinging; | |
state.lastAcceleration = _controller.Velocity - state.lastVelocity; | |
state.lastVelocity = _controller.Velocity; | |
state.lastGrounded = _controller.IsGrounded; | |
state.lastCollisionState = _controller.CollisionState; | |
} | |
private void Interact() | |
{ | |
var nest = state.currentInteractable.GetComponent<Nest>(); | |
if (nest) | |
{ | |
GameManager.Instance.SetNestInCurrentScene(nest); | |
GameManager.Instance.WriteSaveState(); | |
return; | |
} | |
var dialogue = state.currentInteractable.GetComponent<ExampleDialogueSequence>(); | |
if (dialogue) | |
{ | |
StartCoroutine(dialogue.Trigger()); | |
return; | |
} | |
} | |
private void Slash() | |
{ | |
// default to using the player's current facing direction for all attacks | |
var aimDirection = Facing == FacingDirection.Right ? PlayerAimDirection.Right : PlayerAimDirection.Left; | |
// player input overrides facing direction | |
if (state.aim.x > 0) | |
aimDirection = PlayerAimDirection.Right; | |
else if (state.aim.x < 0) | |
aimDirection = PlayerAimDirection.Left; | |
// vertical aiming has priority over horizontal aiming | |
if (Mathf.Approximately(state.aim.y, -1)) | |
{ | |
// the player cant swipe down if they're grounded | |
if (!_controller.IsGrounded) | |
aimDirection = PlayerAimDirection.Down; | |
} | |
else if (Mathf.Approximately(state.aim.y, 1)) | |
aimDirection = PlayerAimDirection.Up; | |
var verticalOffsetFromOrigin = _controller.BoxCollider.size.y / 2f; | |
var slashPosition = aimDirection switch | |
{ | |
PlayerAimDirection.Right => new Vector3(horizontalSlashOffset, 0, 0), | |
PlayerAimDirection.Down => new Vector3(0, -verticalSlashOffset, 0), | |
PlayerAimDirection.Left => new Vector3(-horizontalSlashOffset, 0, 0), | |
PlayerAimDirection.Up => new Vector3(0, verticalSlashOffset, 0), | |
_ => throw new ArgumentOutOfRangeException() | |
}; | |
slashPosition.y += verticalOffsetFromOrigin; | |
var obj = Instantiate(slashPrefab, transform); | |
obj.transform.localPosition = slashPosition; | |
var slash = obj.GetComponent<SlashController>(); | |
slash.slashDirection = aimDirection switch | |
{ | |
PlayerAimDirection.Right => SlashDirection.Right, | |
PlayerAimDirection.Down => SlashDirection.Down, | |
PlayerAimDirection.Left => SlashDirection.Left, | |
PlayerAimDirection.Up => SlashDirection.Up, | |
_ => throw new ArgumentOutOfRangeException() | |
}; | |
slash.damage = slashDamage; | |
OnSlash.Invoke(slash.gameObject, slash.slashDirection, _controller.IsGrounded); | |
} | |
private void GreaterSlash() | |
{ | |
// default to using the player's current facing direction for all attacks | |
var aimDirection = Facing == FacingDirection.Right ? PlayerAimDirection.Right : PlayerAimDirection.Left; | |
// player input overrides facing direction | |
if (state.aim.x > 0) | |
aimDirection = PlayerAimDirection.Right; | |
else if (state.aim.x < 0) | |
aimDirection = PlayerAimDirection.Left; | |
var slashPosition = aimDirection switch | |
{ | |
PlayerAimDirection.Right => new Vector3(horizontalSlashOffset, 0, 0), | |
PlayerAimDirection.Left => new Vector3(-horizontalSlashOffset, 0, 0), | |
_ => throw new ArgumentOutOfRangeException() | |
}; | |
var verticalOffsetFromOrigin = _controller.BoxCollider.size.y / 2f; | |
slashPosition.y += verticalOffsetFromOrigin; | |
var obj = Instantiate(greaterSlashPrefab, transform); | |
obj.transform.localPosition = slashPosition; | |
var greaterSlash = obj.GetComponent<GreaterSlashController>(); | |
greaterSlash.slashDirection = aimDirection switch | |
{ | |
PlayerAimDirection.Right => SlashDirection.Right, | |
PlayerAimDirection.Left => SlashDirection.Left, | |
_ => throw new ArgumentOutOfRangeException() | |
}; | |
greaterSlash.damage = (int)(slashDamage * greaterSlashDamageMultiplier); | |
Destroy(obj, greaterSlashTime); | |
// TODO: OnGreaterSlash | |
} | |
private void OnGUI() | |
{ | |
if (!Application.isEditor) | |
return; | |
var guiStyle = new GUIStyle | |
{ | |
normal = new GUIStyleState | |
{ | |
textColor = Color.white | |
}, | |
}; | |
var index = 0; | |
void DebugLabel(string key, object value) | |
{ | |
GUI.Label(new Rect(10, 10 + 24 * index, 200f, 24f), $"{key}: {value}", guiStyle); | |
index++; | |
} | |
DebugLabel("IsGrounded", _controller.IsGrounded); | |
DebugLabel("Jumping", state.Jumping); | |
DebugLabel("Jump Control timer", state.jumpControlTimer); | |
DebugLabel("Jump Released", state.jumpReleased); | |
DebugLabel("Health", Health); | |
DebugLabel("Invulnerable", PostDamageInvincible); | |
DebugLabel("Idle", state.Idle); | |
DebugLabel("NoHorizontalWishMovement", state.NoHorizontalWishMovement); | |
DebugLabel("Slashing", state.Slashing); | |
DebugLabel("AnyRecoil", state.AnyRecoil); | |
} | |
public void Heal(int amount) | |
{ | |
Health = Math.Clamp(Health + amount, 0, maxNormalHealth); | |
OnHealed.Invoke(amount); | |
} | |
public void Damage(PlayerDamageInfo info) | |
{ | |
if (!IsAlive || PostDamageInvincible || _cannotTakeDamage) | |
return; | |
if (HasInvulnerableFrames && !info.BypassInvulnerableFrames) | |
return; | |
Health -= info.Amount; | |
OnDamaged.Invoke(info); | |
if (Health <= 0) | |
{ | |
Health = 0; | |
OnDead.Invoke(info); | |
GameManager.Instance.StartPlayerDeathRespawnSequence(); | |
return; | |
} | |
if (info.Recoverable) | |
{ | |
state.damageInvincibilityTimer = damageInvincibilityTime; | |
return; | |
} | |
GameManager.Instance.StartPlayerSafeSpotTeleportSequence(); | |
} | |
public bool TryPickupFeather(FeatherType type) | |
{ | |
Debug.Assert(type != FeatherType.None); | |
if (Feather != FeatherType.None) | |
return false; | |
Feather = type; | |
OnFeatherPickup.Invoke(type); | |
return true; | |
} | |
public void AddRecoilAgainst(DamageDirection direction, bool longRecoil) | |
{ | |
_recoilQueue.Enqueue((direction, longRecoil)); | |
} | |
public void TeleportGrounded(Vector2 target) | |
{ | |
var trans = transform; | |
var currentPos = trans.position; | |
currentPos.x = target.x; | |
currentPos.y = target.y; | |
trans.position = currentPos; | |
//_controller.WarpToGrounded(); | |
state.lastGrounded = true; | |
} | |
public void WarpToGround() => _controller.WarpToGrounded(); | |
public IEnumerator MoveToHorizontal(float x, float? speed = null) | |
{ | |
var originalSpeed = moveSpeed; | |
if (speed != null) | |
moveSpeed = speed.Value; | |
if (Mathf.Approximately(transform.position.x, x)) | |
yield break; | |
if (transform.position.x < x) | |
{ | |
while (transform.position.x < x) | |
{ | |
state.aim.x = 1f; | |
yield return new WaitForFixedUpdate(); | |
} | |
} | |
else | |
{ | |
while (transform.position.x > x) | |
{ | |
state.aim.x = 1f; | |
yield return new WaitForFixedUpdate(); | |
} | |
} | |
if (speed != null) | |
moveSpeed = originalSpeed; | |
} | |
public IEnumerator JumpUp(FacingDirection direction, float releaseAtY, float moveSpeedMultiplier = 1f) | |
{ | |
var oldMoveSpeed = moveSpeed; | |
moveSpeed *= moveSpeedMultiplier; | |
var aim = direction == FacingDirection.Right ? 1f : -1f; | |
while (transform.position.y < releaseAtY) | |
{ | |
state.aim.x = aim; | |
state.jumpControlTimer = 1f; | |
yield return new WaitForFixedUpdate(); | |
} | |
state.jumpControlTimer = 0f; | |
while (!_controller.IsGrounded) | |
{ | |
state.aim.x = aim; | |
yield return new WaitForFixedUpdate(); | |
} | |
state.aim.x = 0f; | |
moveSpeed = oldMoveSpeed; | |
} | |
public void ResetStateFromRespawn(bool inNest = true) | |
{ | |
Health = maxNormalHealth; | |
_controller.Velocity = Vector3.zero; | |
state.Reset(); | |
// TODO: nesting, state.inNest = inNest; | |
} | |
[DevConsoleCommand( | |
name: "damage_player", | |
aliases: "", | |
helpText: "Send damage to the player", | |
parameterHelpText: "Amount of damage to send" | |
)] | |
[UsedImplicitly] | |
private static void DamagePlayer(int? amount) | |
{ | |
var player = FindObjectOfType<PlayerController>(); | |
player.Damage(new PlayerDamageInfo(amount ?? 1, player.gameObject)); | |
} | |
[DevConsoleCommand( | |
name: "heal_player", | |
aliases: "", | |
helpText: "Send health to the player", | |
parameterHelpText: "Amount of health to send" | |
)] | |
[UsedImplicitly] | |
private static void HealPlayer(int? amount) | |
{ | |
FindObjectOfType<PlayerController>().Heal(amount ?? 1); | |
} | |
[DevConsoleCommand( | |
name: "give_feather", | |
aliases: "", | |
helpText: "Give a feather to the player", | |
parameterHelpText: "None, GroundSlam, GreaterSlash")] | |
[UsedImplicitly] | |
private static void GiveFeather(string feather) | |
{ | |
var featherType = feather.ToLower() switch | |
{ | |
"none" => FeatherType.None, | |
"groundslam" or "slam" => FeatherType.GroundSlam, | |
"greaterslash" or "slash" => FeatherType.GreaterSlash, | |
_ => throw new ArgumentOutOfRangeException(nameof(feather)) | |
}; | |
GameManager.Instance.player.Feather = featherType; | |
} | |
} | |
public enum FeatherType | |
{ | |
None, | |
GroundSlam, | |
GreaterSlash | |
} | |
public enum FacingDirection | |
{ | |
Right, | |
Left | |
} | |
public enum PlayerAimDirection | |
{ | |
Right, | |
Down, | |
Left, | |
Up, | |
} | |
/// <summary> | |
/// | |
/// </summary> | |
/// <param name="Amount"></param> | |
/// <param name="Source"></param> | |
/// <param name="Recoverable"> | |
/// Whether or not the player can immediately recover from this attack. If false, then the handler should reset the | |
/// player's position to the last safe position | |
/// </param> | |
public sealed record PlayerDamageInfo(int Amount, GameObject Source, bool Recoverable = true, bool BypassInvulnerableFrames = false); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment