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); } } }