1120 lines
44 KiB
TypeScript
1120 lines
44 KiB
TypeScript
// Movement/Components/AC_Movement.ts
|
|
|
|
import type { AC_DebugHUD } from '#root/Debug/Components/AC_DebugHUD.ts';
|
|
import { BFL_Vectors } from '#root/Math/Libraries/BFL_Vectors.ts';
|
|
import { E_MovementState } from '#root/Movement/Enums/E_MovementState.ts';
|
|
import { E_SurfaceType } from '#root/Movement/Enums/E_SurfaceType.ts';
|
|
import { S_AngleThresholds } from '#root/Movement/Structs/S_AngleThresholds.ts';
|
|
import { ActorComponent } from '#root/UE/ActorComponent.ts';
|
|
import type { CapsuleComponent } from '#root/UE/CapsuleComponent.ts';
|
|
import { EDrawDebugTrace } from '#root/UE/EDrawDebugTrace.ts';
|
|
import { ETraceTypeQuery } from '#root/UE/ETraceTypeQuery.ts';
|
|
import type { Float } from '#root/UE/Float.ts';
|
|
import { HitResult } from '#root/UE/HitResult.ts';
|
|
import type { Integer } from '#root/UE/Integer.ts';
|
|
import { MathLibrary } from '#root/UE/MathLibrary.ts';
|
|
import { Rotator } from '#root/UE/Rotator.ts';
|
|
import { StringLibrary } from '#root/UE/StringLibrary.ts';
|
|
import { SystemLibrary } from '#root/UE/SystemLibrary.ts';
|
|
import { Vector } from '#root/UE/Vector.ts';
|
|
|
|
/**
|
|
* Movement System Component
|
|
* Core deterministic movement system for 3D platformer
|
|
* Handles surface classification and movement physics calculations
|
|
*/
|
|
export class AC_Movement extends ActorComponent {
|
|
// ════════════════════════════════════════════════════════════════════════════════════════
|
|
// FUNCTIONS
|
|
// ════════════════════════════════════════════════════════════════════════════════════════
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
// Surface Detection
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Classify surface type based on normal vector
|
|
* @param SurfaceNormal - Normalized surface normal vector
|
|
* @returns Surface type classification
|
|
* @example
|
|
* // Classify flat ground
|
|
* ClassifySurface(new Vector(0,0,1), thresholds) // returns E_SurfaceType.Walkable
|
|
* @pure true
|
|
* @category Surface Detection
|
|
*/
|
|
public ClassifySurface(SurfaceNormal: Vector): E_SurfaceType {
|
|
const SurfaceAngle = BFL_Vectors.GetSurfaceAngle(SurfaceNormal);
|
|
|
|
/**
|
|
* Check if angle is within walkable range
|
|
*/
|
|
const IsWalkableAngle = (walkableAngle: Float): boolean =>
|
|
SurfaceAngle <= walkableAngle;
|
|
|
|
/**
|
|
* Check if angle is within steep slope range
|
|
*/
|
|
const IsSteepSlopeAngle = (steepSlopeAngle: Float): boolean =>
|
|
SurfaceAngle <= steepSlopeAngle;
|
|
|
|
/**
|
|
* Check if angle is within wall range
|
|
*/
|
|
const IsWallAngle = (wallAngle: Float): boolean =>
|
|
SurfaceAngle <= wallAngle;
|
|
|
|
if (IsWalkableAngle(this.AngleThresholdsRads.Walkable)) {
|
|
return E_SurfaceType.Walkable;
|
|
} else if (IsSteepSlopeAngle(this.AngleThresholdsRads.SteepSlope)) {
|
|
return E_SurfaceType.SteepSlope;
|
|
} else if (IsWallAngle(this.AngleThresholdsRads.Wall)) {
|
|
return E_SurfaceType.Wall;
|
|
} else {
|
|
return E_SurfaceType.Ceiling;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if surface allows normal walking movement
|
|
* @param SurfaceType - Surface type to check
|
|
* @returns True if surface is walkable
|
|
* @pure true
|
|
* @category Surface Detection
|
|
*/
|
|
private IsSurfaceWalkable(SurfaceType: E_SurfaceType): boolean {
|
|
return SurfaceType === E_SurfaceType.Walkable;
|
|
}
|
|
|
|
/**
|
|
* Check if surface causes sliding behavior
|
|
* @param SurfaceType - Surface type to check
|
|
* @returns True if surface is steep slope
|
|
* @pure true
|
|
* @category Surface Detection
|
|
*/
|
|
private IsSurfaceSteep(SurfaceType: E_SurfaceType): boolean {
|
|
return SurfaceType === E_SurfaceType.SteepSlope;
|
|
}
|
|
|
|
/**
|
|
* Check if surface blocks movement (collision)
|
|
* @param SurfaceType - Surface type to check
|
|
* @returns True if surface is a wall
|
|
* @pure true
|
|
* @category Surface Detection
|
|
*/
|
|
private IsSurfaceWall(SurfaceType: E_SurfaceType): boolean {
|
|
return SurfaceType === E_SurfaceType.Wall;
|
|
}
|
|
|
|
/**
|
|
* Check if surface is overhead (ceiling)
|
|
* @param SurfaceType - Surface type to check
|
|
* @returns True if surface is ceiling
|
|
* @pure true
|
|
* @category Surface Detection
|
|
*/
|
|
private IsSurfaceCeiling(SurfaceType: E_SurfaceType): boolean {
|
|
return SurfaceType === E_SurfaceType.Ceiling;
|
|
}
|
|
|
|
/**
|
|
* Check if no surface detected (airborne state)
|
|
* @param SurfaceType - Surface type to check
|
|
* @returns True if no surface contact
|
|
* @pure true
|
|
* @category Surface Detection
|
|
*/
|
|
private IsSurfaceNone(SurfaceType: E_SurfaceType): boolean {
|
|
return SurfaceType === E_SurfaceType.None;
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
// Movement Processing
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Process movement input from player controller
|
|
* Normalizes input and calculates target velocity
|
|
* @param InputVector - Raw input from WASD/gamepad stick
|
|
* @param DeltaTime - Time since last frame for frame-rate independence
|
|
* @category Movement Processing
|
|
*/
|
|
public ProcessMovementInput(InputVector: Vector, DeltaTime: Float): void {
|
|
if (this.IsInitialized) {
|
|
this.InputMagnitude = MathLibrary.VectorLength(InputVector);
|
|
|
|
this.TargetRotation = this.CalculateTargetRotation(InputVector);
|
|
this.UpdateCharacterRotation(DeltaTime);
|
|
|
|
this.LastGroundHit = this.CheckGround();
|
|
|
|
this.IsGrounded = this.LastGroundHit.BlockingHit;
|
|
|
|
// Only process movement on walkable surfaces
|
|
if (this.IsSurfaceWalkable(this.CurrentSurface) && this.IsGrounded) {
|
|
this.ProcessGroundMovement(InputVector, DeltaTime);
|
|
} else {
|
|
this.ApplyFriction(DeltaTime);
|
|
}
|
|
|
|
this.ApplyGravity();
|
|
this.UpdateMovementState();
|
|
this.UpdateCurrentSpeed();
|
|
this.ApplyMovementWithSweep(DeltaTime);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process ground-based movement with acceleration and max speed limits
|
|
* @param InputVector - Normalized input direction
|
|
* @param DeltaTime - Frame delta time
|
|
* @category Movement Processing
|
|
*/
|
|
private ProcessGroundMovement(InputVector: Vector, DeltaTime: Float): void {
|
|
if (this.InputMagnitude > 0.01) {
|
|
const CalculateTargetVelocity = (inputVector: Vector): Vector =>
|
|
new Vector(
|
|
MathLibrary.Normal(inputVector).X * this.MaxSpeed,
|
|
MathLibrary.Normal(inputVector).Y * this.MaxSpeed,
|
|
MathLibrary.Normal(inputVector).Z * this.MaxSpeed
|
|
);
|
|
|
|
this.CurrentVelocity = MathLibrary.VInterpTo(
|
|
new Vector(this.CurrentVelocity.X, this.CurrentVelocity.Y, 0),
|
|
CalculateTargetVelocity(InputVector),
|
|
DeltaTime,
|
|
this.Acceleration
|
|
);
|
|
} else {
|
|
// Apply friction when no input
|
|
this.ApplyFriction(DeltaTime);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply friction to horizontal velocity
|
|
* @param DeltaTime - Frame delta time
|
|
* @category Movement Processing
|
|
*/
|
|
private ApplyFriction(DeltaTime: Float): void {
|
|
this.CurrentVelocity = MathLibrary.VInterpTo(
|
|
new Vector(this.CurrentVelocity.X, this.CurrentVelocity.Y, 0),
|
|
new Vector(0, 0, this.CurrentVelocity.Z),
|
|
DeltaTime,
|
|
this.Friction
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Apply gravity to vertical velocity
|
|
* @category Movement Processing
|
|
*/
|
|
private ApplyGravity(): void {
|
|
if (!this.IsGrounded) {
|
|
const ApplyGravityForce = (velocityZ: Float): Float =>
|
|
velocityZ - this.Gravity;
|
|
|
|
// Apply gravity when airborne
|
|
this.CurrentVelocity = new Vector(
|
|
this.CurrentVelocity.X,
|
|
this.CurrentVelocity.Y,
|
|
ApplyGravityForce(this.CurrentVelocity.Z)
|
|
);
|
|
} else {
|
|
// Zero out vertical velocity when grounded
|
|
this.CurrentVelocity = new Vector(
|
|
this.CurrentVelocity.X,
|
|
this.CurrentVelocity.Y,
|
|
0
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update movement state based on current conditions
|
|
* @category Movement Processing
|
|
*/
|
|
private UpdateMovementState(): void {
|
|
if (!this.IsGrounded) {
|
|
this.MovementState = E_MovementState.Airborne;
|
|
} else if (this.InputMagnitude > 0.01) {
|
|
this.MovementState = E_MovementState.Walking;
|
|
} else {
|
|
this.MovementState = E_MovementState.Idle;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update current speed for debug display
|
|
* @category Movement Processing
|
|
*/
|
|
private UpdateCurrentSpeed(): void {
|
|
// Calculate horizontal speed only (ignore vertical component)
|
|
this.CurrentSpeed = MathLibrary.VectorLength(
|
|
new Vector(this.CurrentVelocity.X, this.CurrentVelocity.Y, 0)
|
|
);
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
// Movement Application
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Apply movement with deterministic sweep collision detection
|
|
* Replaces direct position update with collision-safe movement
|
|
* @param DeltaTime - Frame delta time
|
|
* @category Movement Application
|
|
* @pure false - modifies actor position
|
|
*/
|
|
private ApplyMovementWithSweep(DeltaTime: Float): void {
|
|
// Get current actor location
|
|
const currentLocation = this.GetOwner().GetActorLocation();
|
|
|
|
// Calculate desired movement delta
|
|
const desiredDelta = new Vector(
|
|
this.CurrentVelocity.X * DeltaTime,
|
|
this.CurrentVelocity.Y * DeltaTime,
|
|
this.CurrentVelocity.Z * DeltaTime
|
|
);
|
|
|
|
// Perform swept movement
|
|
const SweepResult = this.PerformDeterministicSweep(
|
|
currentLocation,
|
|
desiredDelta,
|
|
DeltaTime
|
|
);
|
|
|
|
// Apply final position
|
|
if (SweepResult.BlockingHit) {
|
|
// Hit something - use safe hit location
|
|
this.GetOwner().SetActorLocation(SweepResult.Location);
|
|
|
|
// Handle collision response (slide along surface)
|
|
const CalculateRemainingDelta = (sweepResultLocation: Vector): Vector =>
|
|
new Vector(
|
|
desiredDelta.X - (sweepResultLocation.X - currentLocation.X),
|
|
desiredDelta.Y - (sweepResultLocation.Y - currentLocation.Y),
|
|
desiredDelta.Z - (sweepResultLocation.Z - currentLocation.Z)
|
|
);
|
|
|
|
const SlideVector = this.HandleSweepCollision(
|
|
SweepResult,
|
|
CalculateRemainingDelta(SweepResult.Location)
|
|
);
|
|
|
|
// Apply slide movement if significant
|
|
if (MathLibrary.VectorLength(SlideVector) > 0.01) {
|
|
this.GetOwner().SetActorLocation(
|
|
new Vector(
|
|
SweepResult.Location.X + SlideVector.X,
|
|
SweepResult.Location.Y + SlideVector.Y,
|
|
SweepResult.Location.Z + SlideVector.Z
|
|
)
|
|
);
|
|
}
|
|
} else {
|
|
// No collision - use final sweep location
|
|
this.GetOwner().SetActorLocation(SweepResult.Location);
|
|
}
|
|
|
|
const ShouldSnapToGround = (
|
|
isGroundHit: boolean,
|
|
currentVelocityZ: Float,
|
|
isCapsuleValid: boolean
|
|
): boolean =>
|
|
this.IsGrounded && isGroundHit && currentVelocityZ <= 0 && isCapsuleValid;
|
|
|
|
if (
|
|
ShouldSnapToGround(
|
|
this.LastGroundHit.BlockingHit,
|
|
this.CurrentVelocity.Z,
|
|
SystemLibrary.IsValid(this.CapsuleComponent)
|
|
)
|
|
) {
|
|
const correctZ =
|
|
this.LastGroundHit.Location.Z +
|
|
this.CapsuleComponent!.GetScaledCapsuleHalfHeight();
|
|
|
|
const CalculateZDifference = (currentLocZ: Float): Float =>
|
|
currentLocZ - correctZ;
|
|
|
|
const zDifference = CalculateZDifference(currentLocation.Z);
|
|
|
|
const IsWithinSnapRange = (): boolean =>
|
|
zDifference > 0.1 && zDifference < this.GroundTraceDistance;
|
|
|
|
if (IsWithinSnapRange()) {
|
|
this.GetOwner().SetActorLocation(
|
|
new Vector(currentLocation.X, currentLocation.Y, correctZ)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
// Character Rotation
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Calculate target rotation based on movement direction
|
|
* Determines what rotation character should have based on movement
|
|
* @param MovementDirection - Camera-relative movement direction from Blueprint
|
|
* @returns Target rotation for character
|
|
* @pure true
|
|
* @category Character Rotation
|
|
*/
|
|
private CalculateTargetRotation(MovementDirection: Vector): Rotator {
|
|
const TargetYaw = (
|
|
movementDirectionX: Float,
|
|
movementDirectionY: Float
|
|
): Float =>
|
|
MathLibrary.RadiansToDegrees(
|
|
MathLibrary.Atan2(movementDirectionY, movementDirectionX)
|
|
);
|
|
|
|
if (MathLibrary.VectorLength(MovementDirection) < 0.01) {
|
|
// No movement, maintain current target rotation
|
|
return this.TargetRotation;
|
|
}
|
|
|
|
// Character should remain level (pitch = 0, roll = 0, only yaw changes)
|
|
return new Rotator(
|
|
0,
|
|
TargetYaw(MovementDirection.X, MovementDirection.Y),
|
|
0
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Update character rotation towards target
|
|
* Uses instant rotation approach similar to Zelda: BotW
|
|
* @param DeltaTime - Time since last frame
|
|
* @category Character Rotation
|
|
*/
|
|
private UpdateCharacterRotation(DeltaTime: Float): void {
|
|
if (this.ShouldRotateToMovement) {
|
|
const ShouldRotate = (): boolean =>
|
|
this.CurrentSpeed >= this.MinSpeedForRotation;
|
|
|
|
if (ShouldRotate()) {
|
|
let yawDifference = this.CurrentRotation.yaw - this.TargetRotation.yaw;
|
|
|
|
if (yawDifference > 180) {
|
|
yawDifference = yawDifference - 360;
|
|
} else if (yawDifference < -180) {
|
|
yawDifference = yawDifference + 360;
|
|
}
|
|
|
|
const NewYaw = (
|
|
currentYaw: Float,
|
|
clampedRotationAmount: Float,
|
|
coef: Float
|
|
): Float => currentYaw + clampedRotationAmount * coef;
|
|
|
|
const ClampedRotationAmount = (deltaTime: Float): Float =>
|
|
MathLibrary.Min(
|
|
this.RotationSpeed * deltaTime,
|
|
MathLibrary.abs(yawDifference)
|
|
);
|
|
|
|
this.RotationDelta = MathLibrary.abs(yawDifference);
|
|
|
|
if (this.RotationDelta > 1) {
|
|
this.IsCharacterRotating = true;
|
|
|
|
this.CurrentRotation = new Rotator(
|
|
0,
|
|
NewYaw(
|
|
this.CurrentRotation.yaw,
|
|
ClampedRotationAmount(DeltaTime),
|
|
yawDifference > 0 ? -1 : 1
|
|
),
|
|
0
|
|
);
|
|
} else {
|
|
this.IsCharacterRotating = false;
|
|
this.CurrentRotation = this.TargetRotation;
|
|
}
|
|
} else {
|
|
this.IsCharacterRotating = false;
|
|
this.RotationDelta = 0.0;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
// Collision State
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Reset collision counter at start of frame
|
|
* Called before movement processing
|
|
* @category Collision State
|
|
* @pure false - modifies SweepCollisionCount
|
|
*/
|
|
private ResetCollisionCounter(): void {
|
|
this.SweepCollisionCount = 0;
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
// Collision Detection
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Calculate adaptive step size based on velocity
|
|
* Fast movement = smaller steps, slow movement = larger steps
|
|
* @param Velocity - Current movement velocity
|
|
* @param DeltaTime - Frame delta time
|
|
* @returns Step size clamped between MinStepSize and MaxStepSize
|
|
* @category Collision Detection
|
|
* @pure true - pure calculation based on inputs
|
|
*/
|
|
private CalculateAdaptiveStepSize(Velocity: Vector, DeltaTime: Float): Float {
|
|
const CalculateFrameDistance = (
|
|
VelocityX: Float,
|
|
VelocityY: Float,
|
|
deltaTime: Float
|
|
): Float =>
|
|
MathLibrary.VectorLength(new Vector(VelocityX, VelocityY)) * deltaTime;
|
|
|
|
const frameDistance = CalculateFrameDistance(
|
|
Velocity.X,
|
|
Velocity.Y,
|
|
DeltaTime
|
|
);
|
|
|
|
const IsMovingTooSlow = (): boolean => frameDistance < this.MinStepSize;
|
|
|
|
if (IsMovingTooSlow()) {
|
|
return this.MaxStepSize;
|
|
} else {
|
|
const CalculateClampedStepSize = (): Float =>
|
|
MathLibrary.ClampFloat(
|
|
frameDistance * 0.5,
|
|
this.MinStepSize,
|
|
this.MaxStepSize
|
|
);
|
|
|
|
return CalculateClampedStepSize();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Perform deterministic sweep collision detection with stepped checks
|
|
* Prevents tunneling by breaking movement into smaller steps
|
|
* @param StartLocation - Starting position for sweep
|
|
* @param DesiredDelta - Desired movement vector
|
|
* @param DeltaTime - Frame delta time for adaptive stepping
|
|
* @returns HitResult with collision information (bBlockingHit, Location, Normal, etc.)
|
|
* @category Collision Detection
|
|
*/
|
|
private PerformDeterministicSweep(
|
|
StartLocation: Vector,
|
|
DesiredDelta: Vector,
|
|
DeltaTime: Float
|
|
): HitResult {
|
|
// Reset collision counter for this frame
|
|
this.ResetCollisionCounter();
|
|
|
|
// Calculate total distance to travel
|
|
const totalDistance = MathLibrary.VectorLength(DesiredDelta);
|
|
|
|
const ShouldPerformSweep = (isValid: boolean): boolean =>
|
|
isValid && totalDistance >= 0.01;
|
|
|
|
if (ShouldPerformSweep(SystemLibrary.IsValid(this.CapsuleComponent))) {
|
|
// Calculate adaptive step size based on velocity
|
|
const stepSize = this.CalculateAdaptiveStepSize(
|
|
this.CurrentVelocity,
|
|
DeltaTime
|
|
);
|
|
|
|
// Current position during sweep
|
|
let currentLocation = StartLocation;
|
|
let remainingDistance = totalDistance;
|
|
|
|
const CalculateNumSteps = (): Integer =>
|
|
MathLibrary.Min(
|
|
MathLibrary.Ceil(totalDistance / stepSize),
|
|
this.MaxCollisionChecks
|
|
);
|
|
|
|
// Perform stepped sweep
|
|
for (let i = 0; i < CalculateNumSteps(); i++) {
|
|
this.SweepCollisionCount++;
|
|
|
|
const CalculateStepSize = (): Float =>
|
|
MathLibrary.Min(stepSize, remainingDistance);
|
|
|
|
// Calculate step distance (last step might be shorter)
|
|
const currentStepSize = CalculateStepSize();
|
|
|
|
const CalculateStepTarget = (desiredDelta: Vector): Vector => {
|
|
const normalizedDelta = MathLibrary.Normal(desiredDelta);
|
|
|
|
return new Vector(
|
|
currentLocation.X + normalizedDelta.X * currentStepSize,
|
|
currentLocation.Y + normalizedDelta.Y * currentStepSize,
|
|
currentLocation.Z + normalizedDelta.Z * currentStepSize
|
|
);
|
|
};
|
|
|
|
// Calculate target position for this step
|
|
const targetLocation = CalculateStepTarget(DesiredDelta);
|
|
|
|
const { OutHit, ReturnValue } = SystemLibrary.CapsuleTraceByChannel(
|
|
currentLocation,
|
|
targetLocation,
|
|
this.CapsuleComponent!.GetScaledCapsuleRadius(),
|
|
this.CapsuleComponent!.GetScaledCapsuleHalfHeight(),
|
|
ETraceTypeQuery.Visibility,
|
|
false,
|
|
[],
|
|
EDrawDebugTrace.ForDuration
|
|
);
|
|
|
|
if (ReturnValue) {
|
|
return OutHit;
|
|
} else {
|
|
currentLocation = targetLocation;
|
|
remainingDistance = remainingDistance - currentStepSize;
|
|
|
|
if (remainingDistance <= 0.01) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
const NoHit = new HitResult();
|
|
NoHit.Location = currentLocation;
|
|
return NoHit;
|
|
} else {
|
|
// If no movement, return empty hit
|
|
const NoHit = new HitResult();
|
|
NoHit.Location = StartLocation;
|
|
return NoHit;
|
|
}
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
// Collision Response
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Handle collision response after sweep hit
|
|
* Calculates slide vector along collision surface
|
|
* @param HitResult - Collision information from sweep
|
|
* @param RemainingDelta - Remaining movement after collision
|
|
* @returns Adjusted movement vector sliding along surface
|
|
* @category Collision Response
|
|
* @pure true
|
|
*/
|
|
private HandleSweepCollision(
|
|
HitResult: HitResult,
|
|
RemainingDelta: Vector
|
|
): Vector {
|
|
if (HitResult.BlockingHit) {
|
|
const ProjectOntoSurface = (
|
|
hitNormal: Vector,
|
|
remainingDelta: Vector
|
|
): Vector => {
|
|
// Project remaining movement onto collision surface
|
|
const dotProduct = MathLibrary.Dot(hitNormal, remainingDelta);
|
|
|
|
return new Vector(
|
|
remainingDelta.X - dotProduct * hitNormal.X,
|
|
remainingDelta.Y - dotProduct * hitNormal.Y,
|
|
remainingDelta.Z - dotProduct * hitNormal.Z
|
|
);
|
|
};
|
|
|
|
return ProjectOntoSurface(HitResult.ImpactNormal, RemainingDelta);
|
|
} else {
|
|
// If no collision, return original delta
|
|
return RemainingDelta;
|
|
}
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
// Ground Detection
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Check if character is standing on walkable ground
|
|
* @returns HitResult with ground information, or null if not grounded
|
|
* @category Ground Detection
|
|
*/
|
|
private CheckGround(): HitResult {
|
|
if (SystemLibrary.IsValid(this.CapsuleComponent)) {
|
|
const StartLocation = new Vector(
|
|
this.GetOwner().GetActorLocation().X,
|
|
this.GetOwner().GetActorLocation().Y,
|
|
this.GetOwner().GetActorLocation().Z -
|
|
this.CapsuleComponent.GetScaledCapsuleHalfHeight()
|
|
);
|
|
|
|
const EndLocation = new Vector(
|
|
StartLocation.X,
|
|
StartLocation.Y,
|
|
StartLocation.Z - this.GroundTraceDistance
|
|
);
|
|
|
|
const { OutHit: groundHit, ReturnValue } =
|
|
SystemLibrary.LineTraceByChannel(
|
|
StartLocation,
|
|
EndLocation,
|
|
ETraceTypeQuery.Visibility,
|
|
false,
|
|
[],
|
|
EDrawDebugTrace.ForDuration
|
|
);
|
|
|
|
if (ReturnValue) {
|
|
if (
|
|
this.ClassifySurface(groundHit.ImpactNormal) ===
|
|
E_SurfaceType.Walkable
|
|
) {
|
|
return groundHit;
|
|
} else {
|
|
return new HitResult();
|
|
}
|
|
} else {
|
|
return new HitResult();
|
|
}
|
|
} else {
|
|
return new HitResult();
|
|
}
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
// System
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Initialize movement system with angle conversion
|
|
* Converts degree thresholds to radians for runtime performance
|
|
* @category System
|
|
*/
|
|
public InitializeMovementSystem(
|
|
CapsuleComponentRef: CapsuleComponent | null = null,
|
|
DebugHUDComponentRef: AC_DebugHUD | null = null
|
|
): void {
|
|
this.CapsuleComponent = CapsuleComponentRef;
|
|
this.DebugHUDComponent = DebugHUDComponentRef;
|
|
this.IsInitialized = true;
|
|
|
|
this.AngleThresholdsRads = {
|
|
Walkable: MathLibrary.DegreesToRadians(
|
|
this.AngleThresholdsDegrees.Walkable
|
|
),
|
|
SteepSlope: MathLibrary.DegreesToRadians(
|
|
this.AngleThresholdsDegrees.SteepSlope
|
|
),
|
|
Wall: MathLibrary.DegreesToRadians(this.AngleThresholdsDegrees.Wall),
|
|
};
|
|
|
|
if (SystemLibrary.IsValid(this.DebugHUDComponent)) {
|
|
this.DebugHUDComponent.AddDebugPage(
|
|
this.DebugPageID,
|
|
'Movement Info',
|
|
60
|
|
);
|
|
}
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
// Debug
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Update debug HUD with current movement info
|
|
* @category Debug
|
|
*/
|
|
public UpdateDebugPage(): void {
|
|
if (SystemLibrary.IsValid(this.DebugHUDComponent)) {
|
|
if (
|
|
this.DebugHUDComponent.ShouldUpdatePage(
|
|
this.DebugPageID,
|
|
SystemLibrary.GetGameTimeInSeconds()
|
|
)
|
|
) {
|
|
this.DebugHUDComponent.UpdatePageContent(
|
|
this.DebugPageID,
|
|
// Constants
|
|
`Max Speed: ${this.MaxSpeed}\n` +
|
|
`Acceleration: ${this.Acceleration}\n` +
|
|
`Friction: ${this.Friction}\n` +
|
|
`Gravity: ${this.Gravity}\n` +
|
|
`Initialized: ${this.IsInitialized}\n` +
|
|
`\n` +
|
|
// Current State
|
|
`Current Velocity: ${StringLibrary.ConvVectorToString(this.CurrentVelocity)}\n` +
|
|
`Speed: ${this.CurrentSpeed}\n` +
|
|
`Is Grounded: ${this.IsGrounded}\n` +
|
|
`Surface Type: ${this.CurrentSurface}\n` +
|
|
`Movement State: ${this.MovementState}\n` +
|
|
`Input Magnitude: ${this.InputMagnitude}` +
|
|
`\n` +
|
|
// Rotation
|
|
`Current Yaw: ${this.CurrentRotation.yaw}\n` +
|
|
`Target Yaw: ${this.TargetRotation.yaw}\n°` +
|
|
`Rotation Delta: ${this.RotationDelta}\n°` +
|
|
`Is Rotating: ${this.IsCharacterRotating}\n` +
|
|
`Rotation Speed: ${this.RotationSpeed}\n°` +
|
|
`Min Speed: ${this.MinSpeedForRotation}` +
|
|
`\n` +
|
|
// Position
|
|
`Location: ${StringLibrary.ConvVectorToString(this.GetOwner().GetActorLocation())}` +
|
|
`\n` +
|
|
// Sweep Collision
|
|
`Collision Checks: ${this.SweepCollisionCount}/${this.MaxCollisionChecks}\n` +
|
|
`Ground Distance: ${this.GroundTraceDistance} cm`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
// Public Interface
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Get maximum horizontal movement speed
|
|
* @returns Maximum speed in UE units per second
|
|
* @category Public Interface
|
|
* @pure true
|
|
*/
|
|
public GetMaxSpeed(): Float {
|
|
return this.MaxSpeed;
|
|
}
|
|
|
|
/**
|
|
* Get current character velocity in world space
|
|
* @returns Current velocity vector (cm/s)
|
|
* @category Public Interface
|
|
* @pure true
|
|
*/
|
|
public GetCurrentVelocity(): Vector {
|
|
return this.CurrentVelocity;
|
|
}
|
|
|
|
/**
|
|
* Get current movement state
|
|
* @returns Current state (Idle/Walking/Airborne)
|
|
* @category Public Interface
|
|
* @pure true
|
|
*/
|
|
public GetMovementState(): E_MovementState {
|
|
return this.MovementState;
|
|
}
|
|
|
|
/**
|
|
* Get current horizontal movement speed
|
|
* @returns Speed magnitude in UE units per second (ignores Z component)
|
|
* @category Public Interface
|
|
* @pure true
|
|
*/
|
|
public GetCurrentSpeed(): Float {
|
|
return this.CurrentSpeed;
|
|
}
|
|
|
|
/**
|
|
* Get current character rotation
|
|
* @returns Current yaw rotation (pitch and roll are always 0)
|
|
* @category Public Interface
|
|
* @pure true
|
|
*/
|
|
public GetCurrentRotation(): Rotator {
|
|
return this.CurrentRotation;
|
|
}
|
|
|
|
/**
|
|
* Check if movement system has been initialized
|
|
* @returns True if InitializeMovementSystem() has been called
|
|
* @category Public Interface
|
|
* @pure true
|
|
*/
|
|
public GetIsInitialized(): boolean {
|
|
return this.IsInitialized;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════════════════
|
|
// VARIABLES
|
|
// ════════════════════════════════════════════════════════════════════════════════════════
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
// Movement Config
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Maximum horizontal movement speed in UE units per second
|
|
* Character cannot exceed this speed through ground movement
|
|
* Used as target velocity cap in ProcessGroundMovement
|
|
* @default 600.0
|
|
* @category Movement Config
|
|
* @instanceEditable true
|
|
*/
|
|
private readonly MaxSpeed: Float = 800.0;
|
|
|
|
/**
|
|
* Speed of velocity interpolation towards target velocity
|
|
* Higher values = faster acceleration, more responsive feel
|
|
* Used with VInterpTo for smooth acceleration curves
|
|
* Value represents interpolation speed, not actual acceleration rate
|
|
* @default 10.0
|
|
* @category Movement Config
|
|
* @instanceEditable true
|
|
*/
|
|
private readonly Acceleration: Float = 10.0;
|
|
|
|
/**
|
|
* Speed of velocity interpolation towards zero when no input
|
|
* Higher values = faster stopping, less sliding
|
|
* Used with VInterpTo for smooth deceleration curves
|
|
* Should typically be <= Acceleration for natural feel
|
|
* @default 8.0
|
|
* @category Movement Config
|
|
* @instanceEditable true
|
|
*/
|
|
private readonly Friction: Float = 8.0;
|
|
|
|
/**
|
|
* Gravitational acceleration in UE units per second squared
|
|
* Applied to vertical velocity when character is airborne
|
|
* Standard Earth gravity ≈ 980 cm/s² in UE units
|
|
* Only affects Z-axis velocity, horizontal movement unaffected
|
|
* @default 980.0
|
|
* @category Movement Config
|
|
* @instanceEditable true
|
|
*/
|
|
private readonly Gravity: Float = 980.0;
|
|
|
|
/**
|
|
* Surface classification angle thresholds in degrees
|
|
* Walkable ≤50°, SteepSlope ≤85°, Wall ≤95°, Ceiling >95°
|
|
* @category Movement Config
|
|
* @instanceEditable true
|
|
*/
|
|
public readonly AngleThresholdsDegrees: S_AngleThresholds = {
|
|
Walkable: 50.0,
|
|
SteepSlope: 85.0,
|
|
Wall: 95.0,
|
|
};
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
// Movement State
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Current character velocity in world space
|
|
* Updated every frame by movement calculations
|
|
* @category Movement State
|
|
*/
|
|
private CurrentVelocity: Vector = new Vector(0, 0, 0);
|
|
|
|
/**
|
|
* Ground contact state - true when character is on walkable surface
|
|
* Used for gravity application and movement restrictions
|
|
* @category Movement State
|
|
*/
|
|
private IsGrounded: boolean = true;
|
|
|
|
/**
|
|
* Type of surface currently under character
|
|
* Determines available movement options
|
|
* @category Movement State
|
|
*/
|
|
private CurrentSurface: E_SurfaceType = E_SurfaceType.Walkable;
|
|
|
|
/**
|
|
* Current movement state of character
|
|
* Used for animation and game logic decisions
|
|
* @category Movement State
|
|
*/
|
|
private MovementState: E_MovementState = E_MovementState.Idle;
|
|
|
|
/**
|
|
* Magnitude of current movement input (0-1)
|
|
* Used for determining if character should be moving
|
|
* @category Movement State
|
|
*/
|
|
private InputMagnitude: Float = 0.0;
|
|
|
|
/**
|
|
* Current movement speed (magnitude of horizontal velocity)
|
|
* Calculated from CurrentVelocity for debug display
|
|
* @category Movement State
|
|
*/
|
|
private CurrentSpeed: Float = 0.0;
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
// Character Rotation Config
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Rotation speed in degrees per second
|
|
* Controls how fast character rotates towards movement direction
|
|
* @category Character Rotation Config
|
|
* @instanceEditable true
|
|
*/
|
|
private readonly RotationSpeed: Float = 720.0;
|
|
|
|
/**
|
|
* Should character rotate when moving
|
|
* Allows disabling rotation system if needed
|
|
* @category Character Rotation Config
|
|
* @instanceEditable true
|
|
*/
|
|
private readonly ShouldRotateToMovement: boolean = true;
|
|
|
|
/**
|
|
* Minimum movement speed to trigger rotation
|
|
* Prevents character from rotating during very slow movement
|
|
* @category Character Rotation Config
|
|
* @instanceEditable true
|
|
*/
|
|
private readonly MinSpeedForRotation: Float = 50.0;
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
// Character Rotation State
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Current character rotation
|
|
* Used for smooth interpolation to target rotation
|
|
* @category Character Rotation State
|
|
*/
|
|
private CurrentRotation: Rotator = new Rotator(0, 0, 0);
|
|
|
|
/**
|
|
* Target character rotation
|
|
* Calculated based on movement direction and camera orientation
|
|
* @category Character Rotation State
|
|
*/
|
|
private TargetRotation: Rotator = new Rotator(0, 0, 0);
|
|
|
|
/**
|
|
* Whether character is currently rotating
|
|
* Used for animation and debug purposes
|
|
* @category Character Rotation State
|
|
*/
|
|
private IsCharacterRotating: boolean = false;
|
|
|
|
/**
|
|
* Angular difference between current and target rotation
|
|
* Used for determining rotation completion and debug display
|
|
* @category Character Rotation State
|
|
*/
|
|
private RotationDelta: Float = 0.0;
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
// Collision Config
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Maximum step size for sweep collision detection
|
|
* @category Collision Config
|
|
* @instanceEditable true
|
|
*/
|
|
private readonly MaxStepSize: Float = 50.0;
|
|
|
|
/**
|
|
* Minimum step size for sweep collision detection
|
|
* @category Collision Config
|
|
* @instanceEditable true
|
|
*/
|
|
private readonly MinStepSize: Float = 1.0;
|
|
|
|
/**
|
|
* Maximum collision checks allowed per frame
|
|
* @category Collision Config
|
|
* @instanceEditable true
|
|
*/
|
|
private readonly MaxCollisionChecks: number = 25;
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
// Collision State
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Current frame collision check counter
|
|
* @category Collision State
|
|
*/
|
|
private SweepCollisionCount: number = 0;
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
// Ground Detection Config
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Distance to trace downward for ground detection
|
|
* Larger values detect ground earlier but may cause "magnet" effect
|
|
* @category Ground Detection Config
|
|
* @instanceEditable true
|
|
* @default 5.0
|
|
*/
|
|
private readonly GroundTraceDistance: Float = 5.0;
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
// Ground Detection State
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Last ground hit result for ground snapping
|
|
* @category Ground Detection State
|
|
*/
|
|
private LastGroundHit: HitResult = new HitResult();
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
// InternalCache
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Runtime cached angle thresholds in radians
|
|
* Converted from degrees during initialization for performance
|
|
* @category Internal Cache
|
|
*/
|
|
private AngleThresholdsRads: S_AngleThresholds = {
|
|
Walkable: 0.0,
|
|
SteepSlope: 0.0,
|
|
Wall: 0.0,
|
|
};
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
// Debug
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Flag indicating if movement system has been initialized
|
|
* Ensures angle thresholds are converted before use
|
|
* @category Debug
|
|
*/
|
|
private IsInitialized = false;
|
|
|
|
/**
|
|
* Debug page identifier for organizing debug output
|
|
* Used by debug HUD to categorize information
|
|
* @category Debug
|
|
* @instanceEditable true
|
|
*/
|
|
private readonly DebugPageID: string = 'MovementInfo';
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
// Components
|
|
// ────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Reference to debug HUD component for displaying camera info
|
|
* Optional, used for debugging purposes
|
|
* @category Components
|
|
*/
|
|
private DebugHUDComponent: AC_DebugHUD | null = null;
|
|
|
|
/**
|
|
* Reference to character's capsule component for collision detection
|
|
* @category Components
|
|
*/
|
|
private CapsuleComponent: CapsuleComponent | null = null;
|
|
}
|