Platformer tutorial, Part 1: The basics

Introduction

Welcome in this first(?) part of doing a game in Unity

I chose to do a platformer since it is a bit of the "classical" game to do, also a really common occurance in NSFW games

With this tutorial we'll see the very basics of what we need for that:

I'm not a teacher or a Unity expert, so I just hope that my experiences so far will be able to help you through this

For this tutorial, I'll assume you already know C# and have some basics in Unity

Saying that, let's start

Creating a new project

First step, let's create a project, on my side I also made a repo for this project which I'll continue over the various tutorial, if you want to get my version of things

For this I'll use Unity 2022.3.21f1 which is the current LTS and create the project as 2D (URP) in case we want to do fancy light stuff

Since I'm pushing on GitHub I'll also import Unity's gitignore from GitHub where I'll append the following:

.vscode/
/[Aa]ssets/TextMesh Pro/
/[Aa]ssets/TextMesh Pro.meta

Which are temp files from VS Code (that I use along Visual Studio) and Text Mesh Pro assets that we can easily re-import when we need them

I will also rename my "SampleScene" scene to "Main", because I like it this way

Now we also need to import some packages we will need, for that go Window > Package Manager, at the top left change Packages: In Project to Packages: Unity Registry and on the top right bar search for:

Our last step, let's clear our collision matrix to get rid of what we don't want
Let's click on Edit > Project Settings and in the new window, let's go in Physics 2D and Layer Collision Matrix, from here let's just tick Disable All, don't close this window we will add things back once we need them
Base collision matrix

Adding a player

Now that we are here, let's make the player

First we will add a basic floor, so let's add a squared sprite in our game and give it a 2D box collider
We will also go in Layer (at the top), Add Layer... and in User Layer 6 we will write "Map", which will be all the map layers we can collide with

Since we are here let's add "Player" for our player, and "Enemy" for our enemies
We can then go back on our collision matrix and update it by ticking what can collide with what:
Player with the map, enemy with the map and player with the enemy

Updated collision matrix

Let's go back on our platformer and change its layer to "Map", and its name to "Floor"
We might also check the static box next to that, since this floor won't move
I'll also change it's scale to (10; 1; 1) and position to (0; -4; 0)

Platform

Let's now add a player on top, we create a new capsule sprite, capsule will, compared to a box, prevent the edges of our colliders to be caught in the corners of our platforms
The counter-part is that it'll however make the physics a bit janky around the edges, so both options are actually discutable

We rename the object Player, change the layer to Player too and our scale to (.1, .1, 1) since my sprite is a bit big

For sprite renderer, and since I can draw as good as a starving sheep, I'll reuse the sprite from our first jam, where I'll import the "Idle" and "Running" folders, inside a "Sprites" folder of my own
From that, I set the Sprite value of my renderer to Idle/Idle_Animation_0000

We then add a capsule collider 2D, the default collider have a circle-like shape so we click on the button next to Edit collider and from the scene view drag & drop the small green squares to make our colliders fit better

Editting player collider

Now let's do our physics, for that we add a Rigidbody 2D

First let's change our Collision Detection from Discrete to Continuous, this trade a bit of performances for better collision handling (instead of checking collisions at the current frame, it also check the in-between positions, allowing us to not miss a collision even at high speed), more information about that on the Unity documentation

In constraints we then check the Freeze Rotation box, since we don't want the physics to make the player rotate on itself
Our next step is creating a Physics Material 2D, I'll do that in my Settings folder, therefore making a new Asset in 2D > Physics Material 2D and change the friction from 0.4 to 0.
This is because by default, if you fall from a platform but continue moving against a it, friction will allow us to stay stuck on it instead of just falling down
By removing the friction we prevent that
Physics material
Once created, we assign the material to our object

If you jump into play mode, our player should now fall and stop when it collides with the platform

Now for the controls, let's add a Player Input script, we will click the Create Actions... button to set our controls, I'll call it Game.inputactions and put it in my Settings folder
By default we have 3 controls:

Now we need to add Jump action, for that we click the + at the left of Actions and name our new action "Jump"
We click on No Binding to assign it, under Use in control scheme select Keyboard/Mouse and in Path write "Space" and select Space (Keyboard)
Next to our Jump action we click the + to create a new binding, this one will be for gamepad.
We click on Add Binding and this time select Gamepad as control scheme, in Path we go in Gamepad and choose Button South
Since I'm only planning to handle keyboard and gamepad, I'll delete things related to touch inputs, joysticks or XR

We click on Save Asset at the top left and we can close the window
Inputs

Going back on our Player, we will also change the Behavior from Send Messages to Invoke Unity Events, this will allow us to create a method in our script, assign it from this component and directly receive events there with our input information
Player

Player Controls

Let's see how to make the player script now, so we can move
Let's create a "Script" folder, inside another folder named "Player" to put all our player related scripts, and our script inside that we will name PlayerController.cs
Script architecture

First let's get rid of all the stuff we don't need, the two System usings that are unused and the Start and Update methods, that are unused too
On top of that, let's wrap our script inside a namespace, which will be the name of our project followed by the names of our containing folders, this will allow to avoid some conflict with potentially others classes coming from others libraries

using UnityEngine;

namespace TutorialPlatformer.Player
{
    public class PlayerController : MonoBehaviour
    {
    }
}

Now let's do the controls, for that we need to read our input and modify the velocity of our rigidbody accordingly
To ensure our rigidbody stays with our object we can first add a RequireComponent attribute on our class

Then let's have a variable to store on rigidbody and load it in the Awake
Awake being called before Start, I usually use that to init all variables that only depend of the current object where I use Start for variable that depends of others scripts

using UnityEngine;

namespace TutorialPlatformer.Player
{
    [RequireComponent(typeof(Rigidbody2D))]
    public class PlayerController : MonoBehaviour
    {
        private Rigidbody2D _rb;

        private void Awake()
        {
            _rb = GetComponent<Rigidbody2D>();
        }
    }
}

Now let's handle reading the inputs, let's create a "OnMove" method that takes an InputAction.CallbackContext parameter, we need to make it public so we can set it from our Player Input component
Since we can only left or right (apart from jumping) we then create a float and read the value of our parameter inside, only taking the X value

(If the using isn't automatically added by your editor, add using UnityEngine.InputSystem; at the top)

The value we get however is normalized by default, which mean that if you're going at the same time up and right, the total length of what ReadValue returns will be 1, and therefore the X component will be around 0.7
Since we don't want to change the speed depending of the up value, let's do a small if else to always have -1, 0 or 1

Now that this is done, let's actually move our player left and right, let's add a FixedUpdate method (we use that compared to Update when we do things that are physics based) and inside let's set our rigidbody velocity, X value will be the variable we assigned, and Y is our current velocity

using UnityEngine;
using UnityEngine.InputSystem;

namespace TutorialPlatformer.Player
{
    [RequireComponent(typeof(Rigidbody2D))]
    public class PlayerController : MonoBehaviour
    {
        private Rigidbody2D _rb;
        private float _xMov;

        private void Awake()
        {
            _rb = GetComponent<Rigidbody2D>();
        }

        private void FixedUpdate()
        {
            _rb.velocity = new(_xMov, _rb.velocity.y);
        }

        public void OnMove(InputAction.CallbackContext value)
        {
            _xMov = value.ReadValue<Vector2>().x;

            if (_xMov < 0f) _xMov = -1f;
            else if (_xMov > 0f) _xMov = 1f;
        }
    }
}

Now let's go back to Unity a bit, let's add our new script to our player, and on our Player Input component let's go in Events > Player > Move and we can drag & drop our player object here, and select PlayerController > OnMove
Added components

If we start the game, we can now move our player left and right!
We can however notice that we are quite slow, so let's solve that

To do things properly, let's use Scriptable Objects, they allow use to group variables like these, often used for game design, at a same place
This can be very conveniant when you (and any non-technical person you might work with) want to modify them without touching the code or messing with your components, or just having a way to have everything centered

Let's create a "SO" folder inside Scripts, and make a PlayerInfo.cs class
New script hierarchy

We do as before, remove useless things, and this time, instead of inheriting MonoBehavior we inherit ScriptableObject, we also add a CreateAssetMenu attribute on our class, and make our new asset point to ScriptableObject/PlayerInfo

Inside our script, let's just create a "Speed" variable, public and type float

using UnityEngine;

namespace TutorialPlatformer.SO
{
    [CreateAssetMenu(menuName = "ScriptableObject/PlayerInfo", fileName = "PlayerInfo")]
    public class PlayerInfo : ScriptableObject
    {
        public float Speed;
    }
}

public variable aren't a good practice but in that specific case, I need one so Unity can access it and we can get it from our player
Making a separate property would work but would make our code quite heavier on the long run

Once this is create, let's go back to our PlayerController script and add a variable we will set from Unity that will contains our info, this time we can be good and set it as private with a SerializeField attribute

[SerializeField]
private PlayerInfo _info;

Let's also update our FixedUpdate and multiply our X velocity by our speed

_rb.velocity = new(_xMov * _info.Speed, _rb.velocity.y);

If it doesn't recognize PlayerInfo, don't forget to add a using for the namespace! using TutorialPlatformer.SO; for me

Now let's go back in Unity and make a ScriptableObjects folder at the root of our Assets folder
If we go back on creating a new asset (Assets/Create/) you now have a ScriptableObject > PlayerInfo sub-menu, let's create it, on our new object we can click on it and set a default speed value from the inspector, let's say 10 for now
Scriptable object default value

Now we just assign the variable from Unity and we are good!
New player component parameter

We can start the game again and see us moving left and right at quite a good spped!

Jumping

What would be a platformer without jumping
So let's code that

First let's modify our scriptable object to add a jump force as a float

public float JumpForce;

And gives it a value in Unity of, let's say, 10
Scriptable object default value

Just the jumping itself is rather straightforward, we first make a "OnJump" method as we did with "OnMove"
Inside we need to check when we press the spacebar, which is if the performed attribute of our parameter is true
When that's the case, we just set our Y velocity to a given value, we don't use AddForce so we don't become a rocket-girl if already having a velocity

public void OnJump(InputAction.CallbackContext value)
{
    if (value.performed)
    {
        _rb.velocity = new(_rb.velocity.x, _info.JumpForce);
    }
}

We don't forget to assign it in the Player Input
Assigning jump callback

We can now start play mode, and spacebar indeed jump, however pressing it many time make us fly away
To prevent that we need to know if we are on the floor, for that we can do a raycast and see if it hits the floor
The goal of a raycast it to fire a ray in a direction and detecting the first object it collides with

Physics2D.Raycast have a lot of potential arguments, the one that interest is this one:

public static RaycastHit2D Raycast(Vector2 origin, Vector2 direction, float distance, int layerMask)

But let's explain a bit the layer mask
If you did the steps above, you created a "Map" layer on User Layer 6, you can also get this value back by doing LayerMask.NameToLayer("Map")
We won't go too deep in the details and generalize a bit, but our layer mask is represented as a int, so 32 bits, which is

0000 0000 0000 0000 0000 0000 0000 0000

To only collide with the Map layer, we need to only tick the bit of our layer, which is 6, so our 6th bit

0000 0000 0000 0000 0000 0000 0010 0000

So achievement that, we can do it that way:

int targetLayer = 1 << LayerMask.NameToLayer("Map");

Let's store our layer mask in a variable so we don't have to call this method everytimes

private int _mapLayer;

private void Awake()
{
    _mapLayer = LayerMask.NameToLayer("Map");

We also store the result of Physics2D.Raycast in its own variable
Which at the end gives us

var hit = Physics2D.Raycast(transform.position, Vector2.down, float.MaxValue, 1 << _mapLayer);

Our raycast only hit something if the collider member of our result is null, so let's do a quick null check
If it hits, let's add a small debug to see the distance of the ray, with the distance member of our variable
Which gives us

public void OnJump(InputAction.CallbackContext value)
{
    if (value.performed)
    {
        var hit = Physics2D.Raycast(transform.position, Vector2.down, float.MaxValue, 1 << _mapLayer);

        if (hit.collider != null)
        {
            _rb.velocity = new(_rb.velocity.x, _info.JumpForce);
            Debug.Log(hit.distance);
        }
    }
}

Let's start play mode, wait for the player to fall on the platform, hit spacebar and check the console
Debug console when jumping
So if we are at 0.9860001 or under on my case, we are on a platform, let's round this to 1 just to give a bit of leaway
And we can update our raycast accordingly

var hit = Physics2D.Raycast(transform.position, Vector2.down, 1f, 1 << _mapLayer);

Let's now tackle our next bug, move your player to the border of the player so half your player is outside it
Being on the edge of a platform
Apart from the fact that the capsule collider makes it a bit of a pain to stay immobile, you'll notice that you can't move
This is because the raycast is done from the center of our player, that in this case is over nothing

So let's do a raycast from the left and right border of our player to check both extremity of it

Let's save the position of our borders in an array, I'm not sure what they will be so I'll put -1 and 1 for now

private readonly float[] _raycastPos = new[] { -1f, 1f };

We can now iterate on it and make our raycast at these positions, changing our origin position

public void OnJump(InputAction.CallbackContext value)
{
    if (value.performed)
    {
        foreach (var d in _raycastPos)
        {
            var hit = Physics2D.Raycast(transform.position + Vector3.right * d, Vector2.down, 1f, 1 << _mapLayer);

            if (hit.collider != null)
            {
                _rb.velocity = new(_rb.velocity.x, _info.JumpForce);
            }
        }
    }
}

To know where we are shooting, let's also use gizmos to debug our ray, it'll also help us in the long run to know what is happening
For that I'll first center our raycast on another method to know if a ray hit so we can reuse it

private bool DoesRayHit(float xPos)
{
    var hit = Physics2D.Raycast(transform.position + Vector3.right * xPos, Vector2.down, 1f, 1 << _mapLayer);

    return hit.collider != null;
}

And edit our previous method accordingly

public void OnJump(InputAction.CallbackContext value)
{
    if (value.performed)
    {
        foreach (var d in _raycastPos)
        {
            if (DoesRayHit(d))
            {
                _rb.velocity = new(_rb.velocity.x, _info.JumpForce);
            }
        }
    }
}

Gizmos are drawn in the OnDrawGizmos method, so let's add it
We will iterate on our raycast positions and edit the color of our gizmos if it hit or not, using Gizmos.color
We now can draw our raycast with Gizmos.DrawLine, which takes the starting position of our ray (transform.position, adding our offset on X) to where it'll land, which is our position plus the size of our raycast, let's store that later part in a variable in case we change it later on

private const float RaycastDist = 1f;

We update the raycast

var hit = Physics2D.Raycast(transform.position + Vector3.right * xPos, Vector2.down, RaycastDist, 1 << _mapLayer);

And our Gizmos code

private void OnDrawGizmos()
{
    foreach (var d in _raycastPos)
    {
        Gizmos.color = DoesRayHit(d) ? Color.green : Color.red;
        var startPos = transform.position + Vector3.right * d;
        Gizmos.DrawLine(startPos, startPos + Vector3.down * RaycastDist);
    }
}

Let's check the scene view
Gizmos debug ray
The rays are too far from the collider, let's twist the values a bit
Gizmos debug ray
On my side, and because the sprite is not well aligned, I end up on -0.6 and 0.44

private readonly float[] _raycastPos = new[] { -.6f, .44f };

Gizmos debug ray

Our last step since we are at it, let's fix our weird jump, since I find the gravity a bit slow I'll increase it in Physics2D > General Settings > Gravity to -50
Gravity update

And increase the jump force to 30
Jump force update

Animations

Our player sprite is a bit static when moving so let's assign it an animation
For that we go in Window > Animation > Animation

We select the player in the hierarchy to have a button to create a new animator (manage transitions between a group of animations) and animation clip there
Creating animation

We click it and create an Animations folder in Assets, and a Player subfolder inside, then put our asset inside, we name it Idle.anim

We then select all our sprites (left click on the first one, stay shift pressed and click on the last one) and drag & drop them in the editor
Adding idle sprites

We click on the drop down menu at the top left containing the name of the current animation (Idle) then select Create New Clip...
We call this one Jump, I don't have a jump animation so I'll just use of my running sprite alone
Adding jump sprites

We repeat this for our running animation
Adding jump sprites

We then go in Window > Animation > Animator, which contains all our transitions and will look like this:
Default animator

Let's create some parameters at the top left, with the small +
We will have two booleans, "isRunning" and "isJumping"
Animator parameters

Jumping is prioritary on everything, then we check the moving condition

Let's start our transitions by the idle to jumping one
Do a right click on Idle, select Make Transition and click on Jump
Animator jump

Let's click on our new arrow, at the right you see transition information
Transition Duration is useful to go back to a default state after a bit of time or to make 2 animations blend together on 3D projects
This is not our case so let's put Transition Duration to 0 and untick Has Exit Time

At the bottom, let's add a new condition, and have "IsJumping" to true
Transition jumping

Reverse condition, jumping to idle is "IsJumping" to false and "IsRunning" to false
Transition jumping

Let's do the rest:

Don't forget the transition duration and here we are!
Transition final

And here we are!
Now let's go back to our PlayerController

As for the Rigidbody 2D, we store the Animator component too

[RequireComponent(typeof(Rigidbody2D), typeof(Animator))]
public class PlayerController : MonoBehaviour
{
    // [...]
    private Animator _anim;

    // [...]
    private void Awake()
    {
        _anim = GetComponent<Animator>();

To set our parameter, we use the SetBool method of our animator, which take the variable we want to update and its corresponding new value
For the running it's quite easy, when receiving an input, we check if it is different than 0

public void OnMove(InputAction.CallbackContext value)
{
    // [...]

    _anim.SetBool("IsRunning", _xMov != 0f);
}

For jumping, we need to set it when we are mid-air
So let's create a method to know if we are on the floor

private bool IsOnFloor()
{
    return _raycastPos.Any(x => DoesRayHit(x));
}

And simplify our jump method

private bool _isMidair;

// [...]

public void OnJump(InputAction.CallbackContext value)
{
    if (value.performed && IsOnFloor())
    {
        _rb.velocity = new(_rb.velocity.x, _info.JumpForce);
    }
}

We can then check if we are mid-air from our Update method and apply the corresponding animator value

private void Update()
{
    if (_isMidair && IsOnFloor())
    {
        _anim.SetBool("IsJumping", !IsOnFloor());
    }
}

We can now start our game and see that it works well but... Animations are extremely fast, this is because we have our animation as a sample rate of 60 and our sprites very close to each other
To solve that let's just go in our animator, select all our clips and in the inspector set the Speed for all of them at let's say 0.1

Our last problem, the player always look right
For that we need to change the flipX value of our Sprite Renderer, for that let's get our component from our script as for the Rigidbody 2D and the Animator

[RequireComponent(typeof(Rigidbody2D), typeof(Animator), typeof(SpriteRenderer))]
public class PlayerController : MonoBehaviour
{
    // [...]
    private SpriteRenderer _sr;

    // [...]
    private void Awake()
    {
        _sr = GetComponent<SpriteRenderer>();

Then in our "OnMove" callback, we flip our sprite if our horizontal speed is inferior to 0
We set it back to right only if it's superior to 0 so if it's at 0 it keeps our last orientation
We already have a check on the position so let's reuse it

public void OnMove(InputAction.CallbackContext value)
{
    _xMov = value.ReadValue<Vector2>().x;

    if (_xMov < 0f)
    {
        _sr.flipX = true;
        _xMov = -1f;
    }
    else if (_xMov > 0f)
    {
        _sr.flipX = false;
        _xMov = 1f;
    }

    // [...]
}

Let's try again, everything works!

Enemies

Let's continue with a very basic enemy: more like a trap, on contact it reset the player position

We create a new sprite, add a collider, layer on enemy and set the sprite
I still didn't learn to draw since the start of this tutorial so I import the sprite from the other project as well

To know what we hit, let's also give it a tag, on the top of the inspector select the tag drop down menu and press Add Tag... and add one named "Enemy" to the list
Tags
Go back on the enemy and assign it

Enemy
My collider eat a bit on my enemy sprite but I base it on my sprite legs so the player don't feel like he got hit by hitting nothing

Let's go back on our PlayerController and check when we hit it

We know that when it'll happen we want to reset our player position, but for that we need to know it
So let's create a variable and store our starting position on Awake

private Vector2 _startPos;

private void Awake()
{
    _startPos = transform.position;

To detect collision we can then use OnCollisionEnter2D method that takes a Collision2D as a parameter
Collision2D just contains information about the collision, but it has a collider member that will give us the object we collided with
We use the CompareTag method on it to ensure we are colliding with the "Enemy" tag
If it's the case, we want to reset our position

private void OnCollisionEnter2D(Collision2D collision)
{
    if (collision.collider.CompareTag("Enemy"))
    {
        transform.position = _startPos;
    }
}

Let's add a quick animation for our enemy, we open our animation window, click on our enemy then click the create button
I drag & drop the sprites in the animation tab and only thing left will be to go in our animator and reduce the animation clip speed, 0.4 seems a good fit to me

Camera

Our last step is the camera, for that let's go in the hierarchy and use the power of cinemachine

We do a right click there and go in Cinemachine > Virtual Camera

Let's put back its base position at (0, 0, -10) for now, the -10 on the Z axis is very important, if you put it to 0 you won't be able to see the player since it'ld be on the same depth position

We can see that we have a warning sign on the Body and Aim part, it's because respectively our Follow and Look At variables are empty

For `Follow we will just drag & drop the player there, since we want the camera to follow it
And for Look At, this might be useful in 3D but for us we want the camera to always look at the background so instead we change the Aim value from Composer to Do nothing

My player being a bit too zoomed-in I'll also change the Lens Ortho Size to 4, the higher, the more zoomed-out it'll be

I would rather have my camera to look where I'm running, since it's where enemies will be at so I'll also change my Body from Transposer to Framing Transposer and change Lookahead Time from 0 to 0.3
Cinemachine

This is it for the base settings of our camera, Cinemachine is a very powerful library and camera can be a very easily overlooked element in video game dev, Game Maker's Toolkit made a very great video on 2D camera which I highly recommand you to look at

Conclusion

We now have our very basic platformer, of course there are still plenty of improvements to do, our "enemies" are for example quite basic for now, a next step would be to make them move, and maybe be killed depending of some condition

Meanwhile don't hesitate to experiment on what was said here, change random values in components, try to do things others way than what I did or try to add new features

I hope you found this article useful and thanks for reading until this point

Zirk

Appendix

Code is available on the GitHub but I'll also throw our current scripts here if you want to quickly grab them, or just use them as a reference for the previvous points

PlayerInfo.cs

using UnityEngine;

namespace TutorialPlatformer.SO
{
    [CreateAssetMenu(menuName = "ScriptableObject/PlayerInfo", fileName = "PlayerInfo")]
    public class PlayerInfo : ScriptableObject
    {
        public float Speed;
        public float JumpForce;
    }
}

PlayerController.cs

using System.Linq;
using TutorialPlatformer.SO;
using UnityEngine;
using UnityEngine.InputSystem;

namespace TutorialPlatformer.Player
{
    [RequireComponent(typeof(Rigidbody2D), typeof(Animator), typeof(SpriteRenderer))]
    public class PlayerController : MonoBehaviour
    {
        [SerializeField]
        private PlayerInfo _info;

        private Rigidbody2D _rb;
        private Animator _anim;
        private SpriteRenderer _sr;

        private float _xMov;
        private int _mapLayer;
        private Vector2 _startPos;

        private readonly float[] _raycastPos = new[] { -.6f, .44f };
        private const float RaycastDist = 1f;

        private void Awake()
        {
            _rb = GetComponent<Rigidbody2D>();
            _anim = GetComponent<Animator>();
            _sr = GetComponent<SpriteRenderer>();

            _startPos = transform.position;
            _mapLayer = LayerMask.NameToLayer("Map");
        }

        private void Update()
        {
            _anim.SetBool("IsJumping", !IsOnFloor());
        }

        private void FixedUpdate()
        {
            _rb.velocity = new(_xMov * _info.Speed, _rb.velocity.y);
        }

        private void OnCollisionEnter2D(Collision2D collision)
        {
            if (collision.collider.CompareTag("Enemy"))
            {
                transform.position = _startPos;
            }
        }

        private bool DoesRayHit(float xPos)
        {
            var hit = Physics2D.Raycast(transform.position + Vector3.right * xPos, Vector2.down, RaycastDist, 1 << _mapLayer);

            return hit.collider != null;
        }

        private bool IsOnFloor()
        {
            return _raycastPos.Any(x => DoesRayHit(x));
        }

        public void OnMove(InputAction.CallbackContext value)
        {
            _xMov = value.ReadValue<Vector2>().x;

            if (_xMov < 0f)
            {
                _sr.flipX = true;
                _xMov = -1f;
            }
            else if (_xMov > 0f)
            {
                _sr.flipX = false;
                _xMov = 1f;
            }

            _anim.SetBool("IsRunning", _xMov != 0f);
        }

        public void OnJump(InputAction.CallbackContext value)
        {
            if (value.performed && IsOnFloor())
            {
                _rb.velocity = new(_rb.velocity.x, _info.JumpForce);
            }
        }

        private void OnDrawGizmos()
        {
            foreach (var d in _raycastPos)
            {
                Gizmos.color = DoesRayHit(d) ? Color.green : Color.red;
                var startPos = transform.position + Vector3.right * d;
                Gizmos.DrawLine(startPos, startPos + Vector3.down * RaycastDist);
            }
        }
    }
}