PVG
34. Sprites — Program Video Games

34. Sprites

For character sprites, we're using the Universal LPC Spritesheet Character Generator. It's a handy tool that lets you mix and match body types, armor, hairstyles, etc., and export spritesheets ready to slice into frames. We'll call our character Ara.

Up until now, entities have been rendered as coloured wireframe spheres. That was fine for testing physics and scene transitions, but it's time to make things look like an actual game. In this lesson we'll:

  1. Replace hardcoded texture and animation enums with data-driven loading from disk
  2. Add sprite animation playback tied to player movement direction
  3. Render animated sprites as camera-facing billboards in the 3D world

New Assets

Drop the following into assets/textures/:

  • ara_idle.png - 2-frame idle spritesheet
  • ara_walk_down.png, ara_walk_left.png, ara_walk_right.png, ara_walk_up.png - 8-frame walk spritesheets for each direction

Note that I added the full spritesheet to the download, but we are instead crafting custom animations into mini sprite sheets

Each frame is 64x64 pixels, laid out horizontally in a single row.

Animation Data Files

Create data/animations/ and add one .json file per animation. These use the simple SJSON format:

frames = [0, 1]
frame_lengths = [0.35, 0.35]
texture_key = "ara_idle"
frame_width = 64
frame_height = 64

The walk animations are the same structure but with 8 frames at 0.15 seconds each. texture_key matches the filename (minus extension) of the texture in assets/textures/.

Create files for: ara_idle.json, ara_walk_down.json, ara_walk_left.json, ara_walk_right.json, ara_walk_up.json.

Kill the Enums

We've been using Texture_Key and Animation_Type enums to reference assets. This doesn't scale - every new texture or animation requires a code change and recompile. We'll switch to string keys loaded from the filesystem.

You may like the type safety of using Enums - there is a trade-off here. A a solo developer, you can update the code any time you want. When working with an artist, they may not be able to update and test animations without your assistance.

1: In assets.odin, delete the entire Texture_Key enum.
2: In animations.odin, delete the Animation_Type enum.
3: In game.odin, change Assets_State.textures from map[Texture_Key]raylib.Texture to map[string]raylib.Texture.
4: Change Animations_State.sprite_animation_definitions from [Animation_Type]Sprite_Animation_Definition to map[string]Sprite_Animation_Definition.

Data-Driven Texture Loading

The old assets_init had a nested texture_load helper and hardcoded paths for each texture. Instead, we'll read every file in the textures directory and load them:

assets_init :: proc(es: ^Events_State, assets: ^Assets_State) {
    dir, dir_err := os.open("assets/textures")
    if dir_err != nil {
        log.panicf("%v", dir_err)
    }
    defer os.close(dir)

    file_infos, fi_err := os.read_dir(dir, 0, context.temp_allocator)
    if fi_err != nil {
        log.panicf("%v", fi_err)
    }

    for file_info in file_infos {
        texture := rl.LoadTexture(fmt.ctprintf(file_info.fullpath))

        key := strings.clone(file_info.name)
        key = strings.trim_suffix(key, ".png")

        assets.textures[key] = texture

        events_enqueue(es, Event{debug_message = fmt.tprintf("Loaded texture `%s`", key)})
    }
}

This may or may not scale depending on your game - you can implement a similar lazy load technique (as we used for models) as your game expands

Note that we use strings.clone on the filename since file_info.name is backed by the temp allocator and will be freed.

Data-Driven Animation Loading

Same idea for animations. The Sprite_Animation_Definition struct gets two new fields:

Sprite_Animation_Definition :: struct {
    name:          string,
    frames:        [dynamic]int,
    frame_lengths: [dynamic]f32,
    texture:       Texture,
    texture_key:   string,
    frame_width:   f32,
    frame_height:  f32,
}

name is the key we'll use to play the animation. texture_key maps to the texture we loaded earlier.

Replace the old animations_init (which manually built soldier_idle and soldier_walk) with a directory scan over data/animations/:

animations_init :: proc(as: ^Animations_State, textures: map[string]Texture) {
    dir, dir_err := os.open("data/animations")
    if dir_err != nil {
        log.panicf("%v", dir_err)
    }
    defer os.close(dir)

    file_infos, fi_err := os.read_dir(dir, 0, context.temp_allocator)
    if fi_err != nil {
        log.panicf("%v", fi_err)
    }

    for file_info in file_infos {
        data, err := os.read_entire_file(file_info.fullpath, context.temp_allocator)
        if err != nil {
            log.errorf("Failed to open file: %s", file_info.fullpath)
            continue
        }

        sprite_animation_def: Sprite_Animation_Definition

        json_err := json.unmarshal(data, &sprite_animation_def, .SJSON)
        if json_err != nil {
            log.panicf("Failed to parse JSON: %v", json_err)
        }

        key := strings.clone(file_info.name)
        key = strings.trim_suffix(key, ".json")

        sprite_animation_def.texture = textures[sprite_animation_def.texture_key]
        sprite_animation_def.name = key

        as.sprite_animation_definitions[key] = sprite_animation_def
    }
}

The SJSON unmarshalling does most of the work - it populates frames, frame_lengths, texture_key, frame_width, and frame_height directly from the file. We then resolve the texture reference and store it.

Add the new imports at the top of animations.odin:

import "core:encoding/json"
import "core:log"
import "core:os"
import "core:strings"

The animation_play Procedure

We need a clean way to tell an entity to start playing a named animation. Add this to animations.odin:

animation_play :: proc(entity: ^Entity, name: string, flags: Sprite_Animation_Flags = {}) {
    gs := cast(^Game_State)context.user_ptr

    // Already playing this animation
    if entity.sprite_animation.definition.name == name do return

    if def, ok := gs.animations.sprite_animation_definitions[name]; ok {
        entity.sprite_animation.current_frame = 0
        entity.sprite_animation.frame_timer = def.frame_lengths[0]
        entity.sprite_animation.definition = def
        entity.sprite_animation.flags = flags
    } else {
        log.errorf("Tried to play animation that does not exist: '%s'", name)
    }
}

The early return on matching name prevents restarting an animation that's already playing - without this, every frame would reset the animation back to frame 0.

Hook Up the Player

In player.odin, set the player's initial animation in player_init:

player.flags += {.Sprite}

animation_play(player, "ara_idle")

Then in player_update, pick the animation based on movement direction. We prefer horizontal animations on diagonals:

anim_name := "ara_idle"

if is.left do anim_name = "ara_walk_left"
else if is.right do anim_name = "ara_walk_right"
else if is.up do anim_name = "ara_walk_up"
else if is.down do anim_name = "ara_walk_down"

animation_play(player, anim_name)

This goes before the movement calculation. The if/else if chain means that if you're holding left+up, you get the left animation. Idle is the default when nothing is pressed.

Billboard Rendering

The old renderer drew every entity as a coloured wireframe sphere. Now, if an entity has animation frames, we render it as a textured billboard instead. Update the entity rendering loop in game_render:

for index in gs.scene.active_entities {
    e := gs.scene.entities[index]

    for i in 0 ..< e.waypoint_count {
        rl.DrawBoundingBox(aabb_from_pos_size(e.waypoints[i], 0.05), rl.YELLOW)
    }

    if len(e.sprite_animation.definition.frames) > 0 {
        frame_width := e.sprite_animation.definition.frame_width
        frame_height := e.sprite_animation.definition.frame_height
        sprite_x_offset :=
            f32(e.sprite_animation.current_frame) *
            e.sprite_animation.definition.frame_width
        size := Vec2{2, 2}

        world_y_offset := Vec3{0, size.y * 0.5, 0}
        world_y_offset.y -= e.collider_radius

        rl.DrawBillboardRec(
            gs.rendering.camera,
            e.sprite_animation.definition.texture,
            {sprite_x_offset, 0, frame_width, frame_height},
            e.position + world_y_offset,
            size,
            rl.WHITE,
        )
    } else {
        color := rl.WHITE
        if e.layer == .NPC {
            color = rl.YELLOW
        } else if e.layer == .Player {
            color = rl.GREEN
        }

        rl.DrawSphereWires(e.position, e.collider_radius, 4, 8, color)
    }
}

DrawBillboardRec renders a sub-rectangle of the spritesheet texture as a quad that always faces the camera. The source rectangle selects the current frame by offsetting sprite_x_offset across the horizontal strip. The world_y_offset positions the sprite so its feet align with the entity's ground position rather than being centred on the collider sphere.

Entities without animation frames still fall back to the wireframe sphere, so NPCs and other non-sprite entities continue to render as before.

Entity Spawners

Now that we have visible sprites, it'd be nice to see more than just the player. Let's hook up the entity spawners we defined in Scene_Data last lesson.

First, add spawners to data/scenes/flat.json. One for an entity that doesn't exist, and one for an entity that does exist):

doors = [
  {name = "CellarDoor", pair = "basement:Exit"}
]
spawners = [
  {pos = [-4.0, 0.5, 4.0], type = "test"},
  {pos = [-5.0, 0.5, 8.0], type = "Test Enemy"}
]

In scene2.odin, after loading the model and before assigning gs.scene, copy the spawners and create entities from them:

// Entity spawners may want to be copied for debug view
ss.entity_spawners = make(type_of(ss.entity_spawners))
append(&ss.entity_spawners, ..scene_data.spawners)

// Spawn entities
for spawner in scene_data.spawners {
    entity_def := entity_def_by_name(spawner.type) or_continue
    entity_handle := entity_create_with_def(ss, entity_def)
    entity_data := entity_get(ss, entity_handle)

    entity_data.position = spawner.pos
    entity_data.position.y += entity_data.collider_radius
}

After creating the entity, we position it at the spawner's coordinates. The Y offset by collider_radius keeps the entity sitting on top of the ground rather than clipping into it.

or_continue is doing some work here - if no entity definition exists with that name, we skip the spawner rather than crashing. To make debugging easier, add an error log in entities.odin when entity_def_by_name fails to find a match:

entity_def_by_name :: proc(name: string) -> (def: Entity_Definition, ok: bool) {
    gs := cast(^Game_State)context.user_ptr
    for ed in gs.entity_definitions {
        if ed.name == name {
            return gs.entity_definitions[ed.id], true
        }
    }

    log.errorf("Tried to get entity which doesn't exist: '%s'", name)

    return {}, false
}

Without this log, a typo in type would silently produce no entity - not fun to debug.