Player
↓
↓
Character
↓
↓
Root
↓
Capsule
Camera Target
Camera
↓
Spring
↓
↓
CinemachineCamera
attackBoxCenter
Let's begin with the camera.
Camera → PlayerCamera.cs
↓
Spring → CameraSpring.cs & cameraLean.cs
↓
↓
CinemachineCamera
// The actual camera
attackBoxCenter
// This doesn't have a script but is used by another script
1. Cameras Position.
2. Cameras Rotation.
[SerializeField] private float sensitvity = 0.1f;
private Vector3 _eulerAngles;
public void Initialize(Transform target)
{
transform.position = target.position;
transform.rotation = target.rotation;
transform.eulerAngles = _eulerAngles = target.eulerAngles;
}
public void UpdateRotation(CameraInput input)
{
_eulerAngles += new Vector3(-input.lookVec.y, input.lookVec.x) * sensitvity;
_eulerAngles.x = Mathf.Clamp(_eulerAngles.x, -89.0f, 89.0f);
transform.eulerAngles = _eulerAngles;
}
public void UpdatePosition(Transform target)
{
transform.position = target.position;
}
But look at it with me.
Initialize, is handling just setting the camera where it should be and with what rotation it should have.
It takes a target transform to do this.
Keep in mind the target as a thing.
UpdateRotation, is where we update the angles of the camera.
We rotate it based on a CameraInput.
CameraInput, is a struct that contains a Vector2.
You can probably surmise that this is getting information from the mouse and rotating accordingly.
We do clamp the X axis cus other wise you can do this
// GIF OFF THE PROBLEM
This is all that PlayerCamera.cs is.
So let's look at the next script; *CameraSpring.cs*
Now remember the structure.
The camera, like the actual camera, in this case called CinemachineCamera, sits on this spring that hosts this script and the next one we will talk about.
public void UpdateSpring(float deltaTime, Vector3 up)
{
transform.localPosition = Vector3.zero;
Spring(ref _springPosition, ref _springVelocity, transform.position, halfLife, frequency, deltaTime);
var localSpringPosition = _springPosition - transform.position;
var springHeight = Vector3.Dot(localSpringPosition, up);
transform.localEulerAngles = new Vector3(-springHeight * angularDisplacement, 0.0f, 0.0f);
transform.localPosition = _springPosition * linearDisplacement;
}
Fairly normal.
Let's move to the line that says Spring.
Spring is a custom math function that looks like this.
private static void Spring(ref Vector3 current, ref Vector3 velocity, Vector3 target, float halfLife, float frequency, float timeStep)
{
var dampingRatio = -Mathf.Log(0.5f) / (frequency * halfLife);
var f = 1.0f + 2.0f * timeStep * dampingRatio * frequency;
var oo = frequency * frequency;
var hoo = timeStep * oo;
var hhoo = timeStep * hoo;
var detInv = 1.0f / (f + hoo);
var detX = f * current + timeStep * velocity + hhoo * target;
var detV = velocity + hoo * (target - current);
current = detX * detInv;
velocity = detV * detInv;
}
But I will show you what this does below.
// make script showing this off here dumbass
if you want to learn more about this then here is the paper on it.
But the effect as you can see is a more “bouncy” camera.
Adding a nice effect to it.
The rest of the UpdateSpring function just updates the Spring's local position and local rotation.
Let's look at the other script called *cameraLean.cs*
Its update Spring function looks like this
public void UpdateSpring(float deltaTime, bool sliding, Vector3 acceleration, Vector3 up)
{
var planarAcceleration = Vector3.ProjectOnPlane(acceleration, up);
var damping = planarAcceleration.magnitude > _dampedAcceleration.magnitude ? attackDamping : decaykDamping;
_dampedAcceleration = Vector3.SmoothDamp(
current: _dampedAcceleration,
target: planarAcceleration,
currentVelocity: ref _dampedAccelerationVel,
smoothTime: damping,
maxSpeed: float.PositiveInfinity,
deltaTime: deltaTime
);
var leanAxis = Vector3.Cross(_dampedAcceleration.normalized, up);
transform.localRotation = Quaternion.identity;
var targetStrength = sliding ? slideStrength : walkStrength;
_smoothStrength = Mathf.Lerp(_smoothStrength, targetStrength, 1.0f - Mathf.Exp(-strengthResponse * deltaTime));
transform.rotation = Quaternion.AngleAxis(-_dampedAcceleration.magnitude * _smoothStrength, leanAxis) * transform.rotation;
}
Well the function starts by taking the player's current acceleration and projecting it onto the up direction of the target.
We use this acceleration twice.
Once to get the damping we want.
The other time we put it into the SmoothDamp vector3 function that unity has.
Now that we have done this we have our _dampedAcceleration.
We use this acceleration twice as well.
First we use it in a cross product between our up and acceleration.
This creates what axis we are going to lean on.
Think of it like this: if we are accelerating forwards then we want the camera to lean back.
To create the effect that the player is being pushed by the wind down.
This is also adaptive to whatever else we put in.
If we add a bit of strafing to this we will lean along with it.
In transform.localRotation = Quaternion.identity; we are setting the rotation of the local transform to be the same as the parents transform.
We get the targetStrength out and slap it into a lerp.
Lerping how strong the effect is over time.
Lastly we apply the rotation.
With this we now have this effect.
// Video of the lean effect
That's how the player's camera works.
It's simple but it's really useful.
It's good to add fluff to make the game feel good as well as look good.
This covers all camera scripts but where are all of these updated?
Well inside of the players late update.
Let me show you that before we move on to the PlayerCharacter.cs.
void LateUpdate()
{
var deltaTime = Time.deltaTime;
var cameraTarget = playerCharacter.getCameraTarget();
var state = playerCharacter.GetState();
PlayerCamera.UpdatePosition(cameraTarget);
CameraSpring.UpdateSpring(deltaTime, cameraTarget.up);
cameraLean.UpdateSpring(deltaTime, state.Stance is Stance.sliding, state.Acceleration, cameraTarget.up);
}
Let's go top to bottom.
PlayerCamera, only cares about getting the target
CameraSpring, wants both delta time and the target's up vector.
Remember it needed the up vector to calculate the springHeight.
deltaTime was used for the spring function.
cameraLean, needs the most out of all of them.
deltaTime for is lerp and damping functions.
“state.Stance is Stance.sliding” just translates to a bool, which is true if you are sliding, otherwise it's false.
state.Acceleration, for the current acceleration.
Lastly cameraTarget.up, for axis calculation and acceleration projection.
That is almost everything *camera* related to this project.
But we will move on here to PlayerCharacter.cs
PlayerCharacter.cs handles all of the movement stuff.
So what is the difference between the PlayerCharacter and the Player scripts?
Well let's look back at the hierarchy first.
Player → Player.cs
↓
Character → KinematicCharacterMotor.cs
& PlayerCharacter.cs & CutAndParry.cs
↓
↓
Root // holds visual components of the player
↓
Capsule // The visual of the player
Camera Target // the target the camera is going towards
So what are their internal differences?
Well the Player.cs handles delegation.
Remember how the camera got all of its updates from the Player.cs script well Player.cs does that for all of the player stuff.
Inputs and updates are its domain.
It gets information and delegates it down to the other scripts as needed.
KinematicCharacterMotor.cs is what I am working with here.
Remember this is a specialisation project and I wanted to learn more about making movements in unity.
Using this motor was the point of this.
CutAndParry.cs is something I didn't finish but you'll get to see later.
(This thing builds towards a project im working on now *After Cut*)
Now because PlayerCharacter.cs is where movement is handled, so it is working a lot with the motor.
I will be going movement by movement.
We will look at 3 functions BeforeCharacterUpdate, AfterCharacterUpdate and UpdateVelocity.
There are like 17 functions in here but the other 14 functions are things like UpdateInput and UpdateBody.
They don't do enough interesting stuff or in interesting ways for me to show off.
Well will go in this order BeforeCharacterUpdate → UpdateVelocity → AfterCharacterUpdate.
BeforeCharacterUpdate, happens before velocity and physics are calculated.
UpdateVelocity, is called before the motor does its thing.
AfterCharacterUpdate, is called when the motor wants to finish up everything in the update cycle.
Now lets actually start
public void BeforeCharacterUpdate(float deltaTime)
{
_tempState = _state;
if (_requestedRun && _state.Stance is Stance.Stand && !_requestedCrouch)
{
_state.Stance = Stance.Running;
}
if (_requestedCrouch && (_state.Stance is Stance.Stand || _state.Stance is Stance.Running))
{
_state.Stance = Stance.Crouch;
motor.SetCapsuleDimensions(
radius: motor.Capsule.radius,
height: crocuhHight,
yOffset: crocuhHight * 0.5f
);
root.localPosition = new Vector3(0.0f, 0.5f, 0.0f);
}
}
1. If you are running or if you are crouching.We do this here because a lot of things that come up late, like your velocity, is dependent on your state.
2. What height you should be.
Now let's look at the big boy and the thing I'm going to be dividing up into movement mode segments.
There are 7 different movement modes
3 of which are;
1. slidingThe rest are
2. wallruning
3. dashing
1. Standing, as in standing stillAs you can tell, it's a bit less interesting.
2. Crouching
3. Running
4. airborn, being in air
So I will be going through the first 3.
Starting with;
if (moving && crouching && shouldSlideStart && (wasStanding || wasInAir || wasRuning))
{
_state.Stance = Stance.sliding;
if (wasInAir)
{
Vector3 groundNormal = motor.GroundingStatus.GroundNormal;
Vector3 tangent = Vector3.ProjectOnPlane(currentVelocity, groundNormal);
// Keep tangential (momentum along slope), discard into-ground velocity
if (tangent.sqrMagnitude > 0.001f)
currentVelocity = tangent.normalized * currentVelocity.magnitude;
}
float effectiveSlideStartSpeed = slideStartSpeed;
if (!_lastState.Grounded && !_requestedCrouchInAir)
{
effectiveSlideStartSpeed = 0.0f;
_requestedCrouchInAir = false;
}
float slideSpeed = Mathf.Max(effectiveSlideStartSpeed, currentVelocity.magnitude);
currentVelocity = motor.GetDirectionTangentToSurface(
direction: currentVelocity,
surfaceNormal: motor.GroundingStatus.GroundNormal) * slideSpeed;
}
This part of the slide is setting up a few things.
It sets up the stance and the velocity.
That's really it.
What we get out from this function in the end is really just the slide velocity but projected onto the ground the player is standing on.
By projecting it onto the ground the player is on we get the slide to follow said ground.
The next part of the slide is here
if (_state.Stance is Stance.Stand or Stance.Crouch or Stance.Running){
/// CODE THAT DOESNT MATTER FOR SLIDE
} else
{
currentVelocity -= currentVelocity * (slideFriction * deltaTime);
Vector3 forceOnSlope = Vector3.ProjectOnPlane(
vector: -motor.CharacterUp,
planeNormal: motor.GroundingStatus.GroundNormal
) * slideGravity;
currentVelocity -= forceOnSlope * deltaTime;
float currentSpeed = currentVelocity.magnitude;
Vector3 targetVelocity = groundedMovement * currentVelocity.magnitude;
Vector3 steerVelocity = currentVelocity;
Vector3 steerForce = (targetVelocity - steerVelocity) * slideSteerAcceleration * deltaTime;
steerVelocity += steerForce;
steerVelocity = Vector3.ClampMagnitude(steerVelocity, currentSpeed);
_state.Acceleration = (steerVelocity - currentVelocity) / deltaTime;
currentVelocity = steerVelocity;
if (currentVelocity.magnitude < slideEndSpeed) _state.Stance = Stance.Crouch;
}
Let's go down the list shall we?
First we apply friction to the slide, so that we slowly lose momentum.
After that we apply slideGravity forcing the player towards the slope.
Increasing the amount of force applied based on a projected vector between the players downvector and the ground's normal vector.
Now we have the strafing part.
We get the targetVelocity we want by taking the groundedMovement, which is our input project onto the ground's normal, and our current velocity's magnitude.
This basically just changes the direction of our velocity.
Magnitude has now gotten a new direction.
The steerVelocity we currently have is just our current velocity.
The steerForce is gotten by simply get the difference between our steerVelocity and our targetVelocity multiplied by our slide acceleration.
We add this force onto our steerVelocity, we are now basically moving our steer towards our targetVelocity.
Lastly we clamp steerVelocity's magnitude to be that of the targetVelocity.
Then we just apply it and update our acceleration.
Last thing this function does is kick us out of sliding if we get to be too slow.
That's all for sliding.
And that was only sliding.
See why I split it?
// wallruning
if (!motor.GroundingStatus.IsStableOnGround && _wallrunColdownTimer < 0.0f && _state.Stance is not Stance.Dashing && !_requestedJump && _isGettingMovementInput && (currentVelocity.sqrMagnitude > wallrunFrechold || _state.Stance is Stance.Running) && (rightWall || leftWall))
{
{// start wallrun
_state.Stance = Stance.Wallruning;
_ungrounedDueToJump = false;
currentVelocity = new Vector3(currentVelocity.x, 0.0f, currentVelocity.z);
}
{// during wallrun
Vector3 wallNormal = rightWall ? rightHit.normal : leftHit.normal;
Vector3 wallForward = Vector3.Cross(wallNormal, motor.CharacterUp);
float chosenStartSpeed = Mathf.Max(wallrunStartSpeed, currentVelocity.magnitude);
float effectiveWallrunSpeed = Mathf.Lerp(
a: chosenStartSpeed,
b: wallrunEndSpeed,v
t: 1.0f - Mathf.Exp(-walkResponse * deltaTime));
Vector3 effectiveForward = (motor.CharacterForward - wallForward).magnitude > (motor.CharacterForward - -wallForward).magnitude ? -wallForward : wallForward;
Vector3 tragetVelocity = (effectiveForward + groundedMovement).normalized * effectiveWallrunSpeed;
Vector3 moveVelocity = Vector3.Lerp( // not learping shit huh
a: currentVelocity,
b: tragetVelocity,
t: 1.0f - Mathf.Exp(-wallrunResponse * deltaTime)
);
_state.Acceleration = moveVelocity - currentVelocity;
currentVelocity = moveVelocity;
}
}
When wallrunning the effect you want is just pushing the player towards the wall and chaninging the vellocity to fallow it intead of the ground or their forward.
Some checks are in there to solve for problems like the player looking to far away form the wall.
We also want the players speed to constanly lowerd as they wallrun and then end the wallrun if they end up going to slowly.
So how do we achive this?
Well lets look att “durring wallrun”
The first part is getting the the fraward direction we need to be moving.
A cross procuct of the players up and the walls normal achives this nicely.
Then to achive the slowing down of the player we just lerp our start speed towards our end speed.
We then use this multiplied with the forward we made earlier to get our target velocity.
We lerp down our current vel towards the traget and then update the acceleretion and the velocity variables.
Now you might have noticed the comment “// not lerping shit huh” well this should be working in therory but in practice well
// VIDEO OF WALLRUNING, -.-' im not home rn so please imagine a somewhat good video
But that is the main part of wallrunning explained.
Lets now look at the dash.
Now I have 2 verstion of dashing depending on if you are wallrunning or not.
Lets look at the not wallruning one first.
_state.Stance = Stance.Dashing;
Vector3 groundedForward = motor.GetDirectionTangentToSurface(direction: motor.CharacterForward, surfaceNormal: motor.GroundingStatus.GroundNormal);
motor.ForceUnground(time: 0.0f);
float effectiveDashSpeed = motor.GroundingStatus.IsStableOnGround ? groundDashSpeed : airDashSpeed;
if (groundedMovement.magnitude > 0.0f) currentVelocity += groundedMovement * effectiveDashSpeed;
else currentVelocity += groundedForward * effectiveDashSpeed;
1. We get the forwardGetting the forward is easy we callculate the tangent between the players forward and and the ground normal.
2. We get the speed
3. We apply everything
Then we take the direction of this line as our forward.
To get the speed we just check if we are currently grounded or not.
And then to apply all of this we just check if groundedMovement is currently giving us anything by checking in magnitude.
groundedMovement tells us if the player is currently giving any input and what direction that input is in.
So, after that we take the direction, we want to dash in multiplied by the speed we want to add and add it to the current velocity.
That’s it for the non wallrun dash.
_state.Stance = Stance.Dashing;
_wallrunColdownTimer = wallrunColdownTime;
Vector3 wallNormal = rightWall ? rightHit.normal : leftHit.normal; // normals can at time point towards the wall fuck
Vector3 comebinedDir = (wallNormal * 0.3f) + motor.CharacterForward.normalized;
currentVelocity = new Vector3(0.0f, currentVelocity.y, currentVelocity.z);
currentVelocity += comebinedDir * airDashSpeed;
So to get the direction we take the normal of the wall and the character forward and add them together.
Note that we are making the input only 30% of its normal size, making it so it affects the outcome way less the direction the player is faceting.
After that we are doing basically just what we have done before.
And that is the dash.
And everything for the player that I wanted to show.
Now let’s see the Player.cs before we move onto the enemy
var characterInput = new CharacterInput
{
// movement input
Rotation = playerCamera.transform.rotation,
Move = input.WASD.ReadValue<Vector2>(),
Jump = input.jump.WasPressedThisFrame(),
JumpSustain = input.jump.IsPressed(),
Crouch = toggleCrouchSlide ? (input.crouch_slide.WasPressedThisFrame() ? CrouchInput.toggle : CrouchInput.None) : (input.crouch_slide.IsPressed() ? CrouchInput.Crouch : CrouchInput.Uncrouch),
Run = toggleRun ? (input.run.WasPressedThisFrame() ? RunInput.toggle : RunInput.None) : (input.run.IsPressed() ? RunInput.Run : RunInput.Unrun),
Dash = input.dash.WasPressedThisFrame(),
};
Lets look at a single line of this as that will be enough to explain most of what is happening here.
We do it like this because this means that all our calculations uses the same data.
From the players rotation to the inputs, all of it is the same across all of the math we are doing.
Run = toggleRun ? (input.run.WasPressedThisFrame() ? RunInput.toggle : RunInput.None) : (input.run.IsPressed() ? RunInput.Run : RunInput.Unrun),
But run is also a togglable value so there is a check for that as well.
All of these inputs is then used down the line mainly as you saw in the character and camera.
playerCharacter.UpdateInput(characterInput);
playerCharacter.UpdateBody(deltaTime);
EnemyBrain.cs has only really 2 functions. Start and Update
Update calls the statemachine to tick function.
Start looks like this
void Start()
{
_enemyReferences = GetComponent<EnemyReferences>();
_enemyStateMachine = new EnemyStateMachine();
CoverArea coverArea = FindFirstObjectByType<CoverArea>();
// States
var runToCover = new EnemyState_RunToCover(_enemyReferences, coverArea);
var delayAfterRun = new EnemyState_Delay(0.1f);
var inCover = new EnemyState_Cover(_enemyReferences, _enemyStateMachine);
_enemyStateMachine.SetState(runToCover);
// Transition
At(runToCover, delayAfterRun, () => runToCover.HasArrivedAtDestination());
At(delayAfterRun, inCover, () => delayAfterRun.IsDone());
void At(IState from, IState to, Func<bool> condition) => _enemyStateMachine.AddTransition(from, to, condition);
void Any(IState to, Func<bool> condition) => _enemyStateMachine.AddAnyTransition(to, condition);
}
Lets look at the states that are bing made here
iState contains 4 functions;
Tick, bascily this states update cycle
OnEnter, called once you enter the state
OnExit, called once you leave the state
GizmoColor, returns a color for debuging
This is an interface so that I am forced to implement all the functions needed for each state.
public void OnEnter()
{
Cover nextCover = this._coverArea.GetRandomCover(_enemyReferences.transform.position);
_enemyReferences._navMeshAgent.SetDestination(nextCover.transform.position);
}
_coverArea is just a holder of Cover(s) which are just points in space.
So in essesce, the enmey is just asks for a random point on this cover to walk to.
then the enemy walks there usuing the navmesh.
public bool HasArrivedAtDestination()
{
return _enemyReferences._navMeshAgent.remainingDistance <= 0.1f;
}
// Transition
At(runToCover, delayAfterRun, () => runToCover.HasArrivedAtDestination());
At(delayAfterRun, inCover, () => delayAfterRun.IsDone());
platform.m_time = std::clamp(platform.m_time += (std::clamp(_delta_time, 0.0f, 1.0f) * time_flow * platform.m_point_travel_time) / platform.diff, 0.0f, 1.0f);