Search code examples
gridgame-developmentgodot

Grid base movement with gravity and step by step - Godot


I'm trying to make a grid-based movement game but when character jumps, it's affected by gravity; too, the character doesn't receive instructions by the keyboard, but it receive instructions step by step when the players push "GO" button

I have the gravity affecting the character, but I don't know how to move the character step by step on grid-based movement. Someone has an idea or some video tutorial

The var "moviendose" is changed to true when the player press GO

`

extends KinematicBody2D

var moviendose = false
var lista_habilidades_jugador = []

onready var tweene = $AnimatedSprite    

const GRAVITY = 9.8 

var velocity = Vector2.ZERO

const def_habilidades = {
    "Habilidad1": Vector2(16,0),
    "Habilidad2": Vector2(-16,0),
    "Habilidad3": Vector2(0,(-GRAVITY*16))
}
func _physics_process(delta):
    velocity.y += GRAVITY
    if lista_habilidades_jugador.size() == 0:
        moviendose = false
    if moviendose == true:
        movimiento()
    velocity = move_and_slide(velocity)

func movimiento():      
    for mov in lista_habilidades_jugador:
        if mov == "Habilidad1":
            velocity = velocity.move_toward(def_habilidades[mov] , 5)
            tweene.flip_h = false
            tweene.play("correr")
        if mov == "Habilidad2":
            velocity = lerp(velocity + def_habilidades[mov], Vector2.ZERO,20)
            tweene.flip_h = true
        if mov == "Habilidad3": 
            velocity = velocity + def_habilidades[mov]

        self.lista_habilidades_jugador.pop_front()

` [bar of movement] (https://i.sstatic.net/cnwqA.png)


Solution

  • I'll break this into issues:

    • Units
    • Sequencing movement
    • Grid movement
    • Overshooting
    • Jump

    Units

    Presumably, this is an earth-like gravity:

    const GRAVITY = 9.8 
    

    Which means this is an acceleration, in meters per second square.

    But this is a 2D enviroment:

    extends KinematicBody2D
    

    So movement units are pixels, not meters. Thus the velocity here is in pixels per seconds:

    velocity = move_and_slide(velocity)
    

    And thus here you are adding meters per second squared to pixels per second, and that is not right:

    velocity.y += GRAVITY
    

    I bet it looks about right anyway, I'll get back to why.

    Do you remember physics? Velocity is displacement over time (speed is distance over time). And we have that time, we call it delta. So this would be meters per second:

    GRAVITY * delta
    

    And if we add a conversion constant for pixels to meters, we have:

    GRAVITY * delta * pixels_per_meter
    

    How much is that constant? How much you want. Presumably something noticiable, you need to check the size of your sprites to come up with some pixel scaling that works for you.

    That means the constant is big, but the time between frames we call detal is small, less than a second. So delta makes the value small and the pixel to meters constant makes it big. And I expect these effects to compensate each other, so it kind of looks good anyway… But having them 1. makes it correct, 2. makes it frame rate independent, 3. gives you control over the scaling.


    The alternative is to come up with the gravity value in pixels per second squared. In other words, instead of figuring out a conversion constant from pixels to meters, tweak the gravity value until it works like you want (which is equivalent to having it pre-multiplied by the constant), and just keep delta:

    GRAVITY * delta
    

    Now, what is this?

    velocity = velocity.move_toward(def_habilidades[mov] , 5)
    

    We are changing the velocity towards some known value at steps of 5. That 5 is a delta velocity (delta meaning change here). You would want an acceleration to make this frame rate independent. Something like this:

    velocity = velocity.move_toward(def_habilidades[mov] , 5 * delta)
    

    Which would also imply to pass delta to the movement method.


    And what the heck is this?

    velocity = lerp(velocity + def_habilidades[mov], Vector2.ZERO,20)
    

    I'll assume the value you are adding to the velocity is also a velocity. I'll challenge that later, it is not the point here.

    The point is that you are using lerp weird. The lerp function is meant to give you a value between the first two arguments. The third arguments controls how close is the result towards the first or second argument. When the third argument is zero, you get the first argument as result. And when the third argument is one, you get the second argument. But the third argument is 20?

    I don't know what the intention here is. Do you want to go to zero and overshoot?

    I don't know. But as far as units are concerned, you are changing the velocity but not by a delta, but by a factor. This is not an acceleration, this is a jerk (look it up).


    Sequencing movement

    Here you have an unusual structure:

    func movimiento(): # you probably will add delta as parameter here
        for mov in lista_habilidades_jugador:
            # PROCESS THE MOVEMENT
    
            self.lista_habilidades_jugador.pop_front()
    

    You iterate over a list, and each iteration - after processing the movement - you remove an element from the list. Yes, the same list.

    If the list stars with three items:

    • The first iteration looks into the first element and removes the third. The list now has two elements.
    • The second iteration looks into the second element and removes the second. The list now has one element.
    • No third iteration.

    Don't do that. In general you want to one element, and process it. The minimum would be like this:

    func movimiento():
        var mov = self.lista_habilidades_jugador.pop_front()
        # PROCESS THE MOVEMENT
    

    The next time you call the movement method it will pull the next element, and so on.


    Ah, but you are calling the movement method each physics frame. Except presumably the motion should take more than one physics frame. So you should hold up until the current movement finishes.

    This is how I would structure it:

    func _physics_process(delta):
        velocity.y += GRAVITY
        movimiento()
        velocity = move_and_slide(velocity)
    

    Then the movement method can set the variable:

    func movimiento():
        # if there are no movements we do nothing
        if lista_habilidades_jugador.size() == 0:
            return
    
        # get the first movement
        var mov = self.lista_habilidades_jugador[0]
        moviendose = true
    
        # PROCESS THE MOVEMENT
    
        if not moviendose: # we will come back to this
            # we are done moving
            self.lista_habilidades_jugador.pop_front()
    

    Grid movement

    To process each movement we need to update the velocity and know when it reached the destination. We can't do that if we just have a velocity.

    In general we don't want to define velocities for grid movement. But displacements. Which will be an issue for the jump but we will get to that.

    Thus, I'll assume that the values you have in your dictionary are not velocities but displacements. In fact, we are going to store a target:

    var target := Vector2.ZERO
    

    So we can keep track to were we have to move towards. And we need to update that when we pull a new movement from the list:

    func movimiento():
        # if there are no movements we do nothing
        if lista_habilidades_jugador.size() == 0:
            return
    
        # get the first movement
        var mov = self.lista_habilidades_jugador[0]
    
        if not moviendose:
            # just starting a new movement
            var displacement = def_habilidades[mov]
            target = position + displacement
    
        moviendose = true
    
        # PROCESS THE MOVEMENT
    
        if not moviendose: # we will come back to this
            # we are done moving
            self.lista_habilidades_jugador.pop_front()
    

    The other thing we need is how long it should take to move. I'll come up with some value, you tweak it as you see fit:

    var step_time := 0.5
    

    Do you remember physics (again)? the velocity is displacement over time.

    velocity = displacement / step_time
    

    So:

    func movimiento():
        # if there are no movements we do nothing
        if lista_habilidades_jugador.size() == 0:
           return
    
        # get the first movement
        var mov = self.lista_habilidades_jugador[0]
    
        if not moviendose:
            # just starting a new movement
            var displacement = def_habilidades[mov]
            velocity = displacement / step_time
            target = position + displacement
    
        moviendose = true
    
        # PROCESS THE MOVEMENT
    
        if not moviendose: # we will come back to this
            # we are done moving
            self.lista_habilidades_jugador.pop_front()
    

    And what is left is finding out if we reached the target. A first approximation is this:

    func movimiento():
        # if there are no movements we do nothing
        if lista_habilidades_jugador.size() == 0:
           return
    
        # get the first movement
        var mov = self.lista_habilidades_jugador[0]
    
        if not moviendose:
            # just starting a new movement
            var displacement = def_habilidades[mov]
            velocity = displacement / step_time
            target = position + displacement
    
        moviendose = position.distance_to(target) > 0
        if not moviendose:
            # we are done moving
            self.lista_habilidades_jugador.pop_front()
    

    And we are done. Right? RIGHT?


    Overshooting

    We are not done. We move some distance each frame, so the distance to the target will likely not hit zero. And that is without talking about floating point errors.

    Thus, instead of finding out if we reached the target, we will find out if we will overshoot the target. And to do that we need to compute how we will move. Start by bringing delta in (I'm also adding type information):

    func _physics_process(delta:float) -> void:
        velocity.y += GRAVITY
        movimiento(delta)
        velocity = move_and_slide(velocity)
    
    func movimiento(delta:float) -> void:
        # if there are no movements we do nothing
        if lista_habilidades_jugador.size() == 0:
           return
    
        # get the first movement
        var mov:String = self.lista_habilidades_jugador[0]
    
        if not moviendose:
            # just starting a new movement
            var displacement:Vector2 = def_habilidades[mov]
            velocity = displacement / step_time
            target = position + displacement
    
        moviendose = position.distance_to(target) > 0
        if not moviendose:
            # we are done moving
            self.lista_habilidades_jugador.pop_front()
    

    Now we can compute how much we will move this frame:

    velocity.length() * delta
    

    And compare that with the distance to the target:

    moviendose = position.distance_to(target) > velocity.length() * delta
    

    Now that we know we will overshoot, we should prevent it. The first idea is to snap to the target:

        if not moviendose:
            # we are done moving
            position = target
            self.lista_habilidades_jugador.pop_front()
    

    For reference I'll also mention that we can compute the velocity to reach the target this frame (within floating point error):

        if not moviendose:
            # we are done moving
            velocity = velocity.normalized() * position.distance_to(target) / delta
            self.lista_habilidades_jugador.pop_front()
    

    Here we normalize the velocity to get just its direction. And do you remember physics? Yes, yes. That is distance over time.

    However we will not use this one, because it would mess with the jump…


    Jump

    But the jump does not work like that. You cannot define a jump the same way. There are six ways to define a vertical jump:

    • By Gravity and Time
    • By Gravity and Speed
    • By Gravity and Height
    • By Time and Speed
    • By Time and Height
    • By Speed and Height

    Defining the jump by gravity and speed is very common, at least among beginners, because you already have a gravity and then you define some upward velocity and you have a jump.

    It is, however, a good idea to define the gravity using height because then you know - by design - how high it can jump which can be useful for scenario design.

    In fact, in the spirit of keeping the representation of movement by displacement, using a definition by gravity and height is convenient. So I'll go with that. Yet, we will still need to compute the velocity, which we will do like this:

    half_jump_time = sqrt(-2.0 * max_height / gravity)
    jump_time = half_jump_time * 2.0
    vertical_speed = -gravity * half_jump_time
    

    That comes from the equations of motion. I'll spare how to come up with that, feel free to look it up.


    However, note that the vertical component will not be the destination vertical position, but the height of the jump. Which is half way through the jump. So for consistency sake, when you specify a jump the displacement will be to the highest point of the jump…

    We will know it is a jump because it has a vertical component at all.

        if not moviendose:
            # just starting a new movement
            var displacement:Vector2 = def_habilidades[mov]
            target = position + displacement
            if displacement.y == 0:
                velocity = displacement / step_time
            else:
                var half_jump_time = sqrt(-2.0 * displacement.y / gravity)
                var jump_time = half_jump_time * 2.0
                velocity.y = -gravity * half_jump_time
                velocity.x = displacement.x / half_jump_time
    

    And that highlights another problem: We cannot say the motion ended when reached the target, we also need to check if the character is on the ground.

    For that effect, I'll have one extra variable (add at the top of the file):

    var target_reached := false
    

    So we can say:

        var distance:float = velocity.length() * delta
        target_reached = target_reached or position.distance_to(target) > distance
        moviendose = not (target_reached and is_on_floor())
        if not moviendose:
            # we are done moving
            target_reached = false
            position = target
            self.lista_habilidades_jugador.pop_front()
    

    Unless I forgot to include something, that should be it.

    Addendum: Yes, I forgot something. When the character landed that is not the target position (because the target position is the mid point of the jump). You could, in theory, snap mid jump, but I don't think that is of much use. Instead consider snapping the position (or issuing a move) once landed to the nearest position of the grid. You can use the method snapped on the vector to find out where it aligns to the grid size.