PVG
10. Animations — Program Video Games

10. Animations

[[programvideogames]]In this lecture we'll set up a simple structure for animations and give our player an Idle animation.

There are many ways to implement 2D animations, including things like exporting frame data in JSON from Aseprite.

We are going to use a simple manual approach for now, with some constraints to make it easy to implement and create new animations.

I want to say up front, a lot of this will be changing.
We want to cover the basics of getting set up and showing stuff on screen.
Then we can use some brain power to figure out how best to refactor.

Get the thing working in a straightforward way first, then if you need to, refactor.

Start by creating a new type:

Animation :: struct {
    size:   Vec2, // Animation frame size
    offset: Vec2, // Line things up with the collider
    start:  int, // Starting column (0-index)
    end:    int, // Ending column (0-index)
    row:    int, // Row (0-index)
    time:   f32, // How long each frame lasts
}

The main constraint is that an animation cannot wrap to a new line.

Here is the player sprite sheet right now (we'll add more animations later)

![[Pasted image 20240914180326.png]]

row 1, 0-9 (the whole first row) is Idle
row 2, 0-2 is Jump
row 2, 3-4 is Jump-Fall-In-Between
row 2, 5-7 is Fall

We'll also want to update our Entity struct with some new fields:

Entity :: struct {
    // existing fields
    texture:            ^rl.Texture,
    animations:         map[string]Animation,
    current_anim_name:  string,
    current_anim_frame: int,
    animation_timer:    f32,
}

Let's load our texture down in main.

Just after we set up the camera:

player_texture := rl.LoadTexture("assets/textures/player_120x80.png")

player_anim_idle := Animation {
    size   = {120, 80},
    offset = {52, 42},
    start  = 0,
    end    = 9,
    row    = 0,
    time   = 0.15,
}

player_anim_jump := Animation {
    size   = {120, 80},
    offset = {52, 42},
    start  = 0,
    end    = 2,
    row    = 1,
    time   = 0.15,
}

player_anim_jump_fall_inbetween := Animation {
    size   = {120, 80},
    offset = {52, 42},
    start  = 3,
    end    = 4,
    row    = 1,
    time   = 0.15,
}

player_anim_fall := Animation {
    size   = {120, 80},
    offset = {52, 42},
    start  = 5,
    end    = 7,
    row    = 1,
    time   = 0.15,
}

With these animations declared, we can alter the loading code for our player:

gs.player_id = entity_create(
    {
        // other fields remain the same
        // Removed flags, no debug draw for player
        texture = &player_texture,
        animations = {
            "idle" = player_anim_idle,
            "jump" = player_anim_jump,
            "jump_fall_inbetween" = player_anim_jump_fall_inbetween,
            "fall" = player_anim_fall,
        },
        current_anim_name = "idle",
    },
)

We'll put in some temporary code to test the animations in the player input section:

if !gs.player_uncontrollable {
    input_x: f32
    if rl.IsKeyDown(.T) do input_x += 1
    if rl.IsKeyDown(.R) do input_x -= 1

    // NOTE: Problems with this:
    // Hard to do jump-fall-in-between frames
    // Frame time doesn't reset - may show short frames
    // Hard to add new animation states

    if rl.IsKeyPressed(.SPACE) && .Grounded in player.flags {
        player.vel.y = -player.jump_force
        player.flags -= {.Grounded}
        player.current_anim_name = "jump"
    }

    if player.vel.y >= 0 {
        if .Grounded not_in player.flags {
            player.current_anim_name = "fall"
        } else {
            player.current_anim_name = "idle"
        }
    }

    player.vel.x = input_x * player.move_speed
}

And here's the code to draw the animated entity, down in the draw section:

for &e in gs.entities {
    if e.texture != nil {
        e.animation_timer -= dt

        // Grab animation from the map
        anim := e.animations[e.current_anim_name]

        // Rectangle on the texture (sprite sheet)
        source := Rect {
            f32(e.current_anim_frame) * anim.size.x,
            f32(anim.row) * anim.size.y,
            anim.size.x,
            anim.size.y,
        }
        // Using a negative number will flip the sprite
        if .Left in e.flags {
            source.width = -source.width
        }
        // Draw the rectangle using our texture at entity position - offset
        rl.DrawTextureRec(e.texture^, source, {e.x, e.y} - anim.offset, rl.WHITE)
    }

    if .Debug_Draw in e.flags && .Dead not_in e.flags {
        rl.DrawRectangleLinesEx(e.collider, 1, e.debug_color)
    }
}

Finally, over in entity.odin, we'll run the timer and change the frame.

Here is the entire entity_update proc:

entity_update :: proc(entities: []Entity, dt: f32) {
    for &e in entities {
        if e.health == 0 && .Immortal not_in e.flags {
            e.flags += {.Dead}
        }

        if len(e.animations) > 0 {
            anim := e.animations[e.current_anim_name]

            // Switch frames?
            e.animation_timer -= dt
            if e.animation_timer <= 0 {
                e.current_anim_frame += 1

                // Loop, TODO: Reverse, Stop
                if e.current_anim_frame > anim.end {
                    e.current_anim_frame = anim.start
                }

                e.animation_timer = anim.time
            }
        }
    }
}

Alright, that's all for our first pass at animating the player!

Run the game now and you should see a breathing, jumping knight!