Skip to content

Instantly share code, notes, and snippets.

@dresswithpockets
Last active March 13, 2023 00:30
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save dresswithpockets/b441e09149634bee5fe66ffa5010f549 to your computer and use it in GitHub Desktop.
Save dresswithpockets/b441e09149634bee5fe66ffa5010f549 to your computer and use it in GitHub Desktop.
#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
}
}
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