using BulletHellTemplate.Core.Events;
using Cysharp.Threading.Tasks;
using System;
using System.Threading;
using UnityEngine;
using static UnityEngine.UI.GridLayoutGroup;
namespace BulletHellTemplate
{
///
/// Handles advanced character movement, including walking, jumping, and being pushed.
/// Requires a CharacterController component to function correctly.
///
[RequireComponent(typeof(CharacterController))]
public class CharacterControllerComponent : MonoBehaviour
{
public float moveSpeed = 5f; // Speed at which the character moves
public float pushForce = 10f; // Force applied when being pushed
public float rotationSpeed = 720f; // Speed of character rotation
public float jumpHeight = 2f; // Height the character can jump
public float gravity = 9.81f; // Gravity applied to the character
public float maxFallVelocity = 40f; // Maximum velocity when falling
[SerializeField] private LayerMask teleportGroundMask = ~0;
[SerializeField] private float teleportGroundProbeUp = 0.5f;
[SerializeField] private float teleportGroundProbeDown = 5.0f;
private CharacterController characterController; // The CharacterController component
private Vector3 moveDirection = Vector3.zero; // Current direction of movement
private Vector3 pushDirection = Vector3.zero; // Direction of any applied push force
private float verticalVelocity; // Vertical velocity for gravity and jumping
private bool isJumping = false; // Indicates if the character is currently jumping
private bool isMovementStopped = false;
private CharacterEntity characterOwner;
private CharacterEntity Owner => characterOwner;
//Unitask
private CancellationTokenSource dashCts;
private CancellationTokenSource knockCts;
private bool OwnerIsStunned => characterOwner && characterOwner.IsStunned;
[HideInInspector]public bool CanRotateWhileStopped { get; private set; } = false;
void Awake()
{
characterController = GetComponent();
characterOwner = GetComponent();
}
void Update()
{
ApplyGravity();
MoveCharacter();
}
private void OnEnable()
{
EventBus.Subscribe(OnDash);
}
private void OnDisable()
{
EventBus.Unsubscribe(OnDash);
}
///
/// Moves the character in a specified direction.
///
/// Direction vector for movement.
public void Move(Vector3 direction)
{
if (GameplayManager.Singleton.IsPaused())
{
moveDirection = Vector3.zero;
return;
}
if (isMovementStopped)
{
if (CanRotateWhileStopped && direction.magnitude > 0)
{
float targetAngle = Mathf.Atan2(direction.x, direction.z) * Mathf.Rad2Deg;
float angle = Mathf.SmoothDampAngle(transform.eulerAngles.y, targetAngle, ref rotationSpeed, 0.1f);
transform.rotation = Quaternion.Euler(0, angle, 0);
}
moveDirection = Vector3.zero;
return;
}
if (direction.magnitude > 0)
{
float targetAngle = Mathf.Atan2(direction.x, direction.z) * Mathf.Rad2Deg;
float angle = Mathf.SmoothDampAngle(transform.eulerAngles.y, targetAngle, ref rotationSpeed, 0.1f);
transform.rotation = Quaternion.Euler(0, angle, 0);
moveDirection = direction.normalized * moveSpeed;
}
else
{
moveDirection = Vector3.zero;
}
}
///
/// Makes the character jump if grounded.
///
public void Jump()
{
if (characterController.isGrounded)
{
isJumping = true;
verticalVelocity = Mathf.Sqrt(2 * jumpHeight * gravity);
}
}
///
/// Applies a force to push the character.
///
/// The force to apply.
public void Push(Vector3 force)
{
pushDirection = force;
}
///
/// Alters the character's movement speed.
///
/// The new speed value.
public void AlterSpeed(float newSpeed)
{
moveSpeed = newSpeed;
}
///
/// Gets the current speed of the character.
///
/// The magnitude of the character's movement velocity.
public float GetCurrentSpeed()
{
Vector3 horizontalVelocity = new Vector3(moveDirection.x + pushDirection.x, 0, moveDirection.z + pushDirection.z);
return horizontalVelocity.magnitude;
}
private void ApplyGravity()
{
if (characterController.isGrounded)
{
if (!isJumping)
{
verticalVelocity = -gravity * Time.deltaTime;
}
else
{
verticalVelocity -= gravity * Time.deltaTime;
isJumping = false;
}
}
else
{
verticalVelocity -= gravity * Time.deltaTime;
if (verticalVelocity < -maxFallVelocity)
{
verticalVelocity = -maxFallVelocity;
}
}
}
private void MoveCharacter()
{
if (isMovementStopped)
{
characterController.Move(Vector3.zero);
return;
}
Vector3 velocity = moveDirection + pushDirection;
velocity.y = verticalVelocity;
if (GameplayManager.Singleton.IsPaused())
{
characterController.Move(Vector3.zero);
}
else
{
characterController.Move(velocity * Time.deltaTime);
}
// Gradually reduce push force over time
pushDirection = Vector3.Lerp(pushDirection, Vector3.zero, Time.deltaTime * pushForce);
}
///
/// Stops the movement of the character temporarily, allowing optional rotation.
///
/// Whether rotation is allowed while movement is stopped.
public void StopMovement(bool allowRotation = false)
{
isMovementStopped = true;
CanRotateWhileStopped = allowRotation;
moveSpeed = 0f;
}
///
/// Resumes the movement of the character.
///
public void ResumeMovement()
{
isMovementStopped = false;
moveSpeed = Owner ? Owner.GetCurrentMoveSpeed()
: moveSpeed;
}
private async void OnDash(PlayerDashEvent evt)
{
if (evt.Target != characterOwner || OwnerIsStunned) return;
await DashAsync(evt.dir, evt.dashSpeed, evt.dashDuration, evt.dashCts);
}
///
/// Cancels any running dash coroutine, if one is active.
///
public void CancelDash()
{
dashCts?.Cancel();
}
///
/// Applies a knock-back impulse that pushes the character away from
/// by units over
/// seconds. Behaves like the monster version,
/// but uses instead of a NavMeshAgent.
///
/// World position the push originates from.
/// Total distance to travel.
/// Total time in seconds.
public void ApplyKnockback(Vector3 from, float dist, float dur)
{
Vector3 dir = (transform.position - from);
dir.y = 0f; // keep push on the ground plane
if (dir.sqrMagnitude < 0.001f) dir = -transform.forward;
dir = dir.normalized;
knockCts?.Cancel(); // abort previous knock-back
knockCts = new CancellationTokenSource();
KnockRoutine(dir, dist, dur, knockCts.Token).Forget();
}
///
/// Performs a dash in the specified direction while ensuring that the character does not pass through walls.
/// The dash increases the movement speed temporarily, locks the movement direction, and ensures the character faces the dash direction.
///
/// The speed to dash at.
/// The duration of the dash in seconds.
/// The direction vector in which to dash, typically coming from the joystick input.
private async UniTask DashAsync(Vector3 direction,
float dashSpeed,
float dashDuration,
CancellationToken cancellationToken)
{
dashCts?.Cancel();
dashCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
Vector3 dashDirection = direction.normalized;
if (dashDirection.sqrMagnitude > 0f)
{
float targetAngle = Mathf.Atan2(dashDirection.x, dashDirection.z) * Mathf.Rad2Deg;
transform.rotation = Quaternion.Euler(0, targetAngle, 0);
}
float originalSpeed = Owner ? Owner.GetCurrentMoveSpeed() : moveSpeed;
moveSpeed = 0f;
float elapsed = 0f;
try
{
while (elapsed < dashDuration)
{
dashCts.Token.ThrowIfCancellationRequested();
Vector3 dashMovement = dashDirection * dashSpeed * Time.deltaTime;
characterController.Move(dashMovement);
elapsed += Time.deltaTime;
await UniTask.Yield(PlayerLoopTiming.Update, dashCts.Token);
}
}
catch (OperationCanceledException) { }
finally
{
moveSpeed = Owner ? Owner.GetCurrentMoveSpeed() : originalSpeed;
}
}
///
/// Internal coroutine that displaces the character while game-pausing,
/// cancels and state-restoration are handled correctly.
///
private async UniTaskVoid KnockRoutine(Vector3 dir, float dist, float dur, CancellationToken token)
{
// Disable normal locomotion but allow facing updates if desired
bool prevStopped = isMovementStopped;
StopMovement(); // freezes MoveCharacter() loop
float elapsed = 0f;
try
{
while (elapsed < dur && !token.IsCancellationRequested)
{
if (GameplayManager.Singleton.IsPaused())
{
await UniTask.Yield(PlayerLoopTiming.Update, token);
continue; // wait while the game is paused
}
// Distance per frame = totalDist / totalTime * Δt
Vector3 step = dir * (dist / dur) * Time.deltaTime;
characterController.Move(step);
elapsed += Time.deltaTime;
await UniTask.Yield(PlayerLoopTiming.Update, token);
}
}
catch (OperationCanceledException) { /* ignored */ }
finally
{
if (!prevStopped) ResumeMovement();
}
}
public void Teleport(Vector3 targetPosition, Quaternion targetRotation, bool snapToGround = false)
{
CancelDash();
knockCts?.Cancel();
moveDirection = Vector3.zero;
pushDirection = Vector3.zero;
verticalVelocity = 0f;
isJumping = false;
Vector3 finalPos = targetPosition;
if (snapToGround)
{
Vector3 rayOrigin = targetPosition + Vector3.up * teleportGroundProbeUp;
if (Physics.Raycast(rayOrigin, Vector3.down, out var hit, teleportGroundProbeDown + teleportGroundProbeUp,
teleportGroundMask, QueryTriggerInteraction.Ignore))
{
finalPos = hit.point + Vector3.up * 0.02f;
}
}
bool wasEnabled = characterController.enabled;
if (wasEnabled) characterController.enabled = false;
transform.SetPositionAndRotation(finalPos, targetRotation);
if (wasEnabled) characterController.enabled = true;
characterController.Move(Vector3.zero);
}
public void Teleport(Vector3 targetPosition, bool snapToGround = false)
{
Teleport(targetPosition, transform.rotation, snapToGround);
}
}
}