Building and Animating a 3D Character in Godot

My initial goal was to create a blockout of the Digimon World game, starting with File City. However, I got sidetracked by various learning experiences along the way. First off, exploring a level without a character is not a thing, right? I could have easily built or borrowed a free-floating camera script that would have taken no time, but what’s the point?

A character in a 3D world needs a few things: a rigged model, a running animation, and a script to control movement. Those are the basic steps, but starting from scratch, both inside and outside of Godot, can be a lot of work to get a good result. After some research, I discovered the excellent Gobot from the talented team at GDQuest:

Interestingly, this little robot can blink. Why is this noteworthy, you might ask? Well, the blinking wasn’t functioning correctly. After some investigation, I found that certain animations were resetting the eyes’ albedo material, rendering the blinking code ineffective. Since I didn’t need these animations, I carefully removed the unnecessary code, allowing the little robot to blink properly (and that’s pretty cool):

Once this side quest was completed, it was time to make the character move! The animation and script for Gobot were well-prepared, making it easy to call each animation. To enable movement, I used the CharacterBody3D node and enlisted some AI to generate a script. The result was something like this:

extends CharacterBody3D

var input_vector := Vector3()
var speed: float = 5.0
var acceleration: float = 10.0
var deceleration: float = 10.0
@onready var model = %Model

# Gravity-related variables
const GRAVITY: float = 9.8 * 3
var gravity_velocity: float = 0.0
var jump_force: float = 10.0
var is_jumping: bool = false

# Animation state tracking
var was_on_floor: bool = true
enum PlayerState { IDLE, RUNNING, FALLING, JUMPING }
var current_state: PlayerState = PlayerState.IDLE

func _ready():
    set_process(true)

func _physics_process(delta: float):
    # Store previous floor state
    was_on_floor = is_on_floor()

    # Handle gravity
    if not is_on_floor():
        gravity_velocity -= GRAVITY * delta
    else:
        gravity_velocity = 0.0
        is_jumping = false

    # Gather input
    input_vector = Vector3(
        Input.get_axis("move_left", "move_right"),
        0.0,
        Input.get_axis("move_up", "move_down")
    ).normalized()

    # Handle character rotation to face movement direction
    if input_vector.length_squared() > 0.1:
        var angle = atan2(input_vector.x, input_vector.z)
        rotation.y = angle

    # Calculate target velocity based on input
    var target_velocity = input_vector * speed

    # Apply acceleration or deceleration to horizontal movement
    if target_velocity.length_squared() > 0.1:
        velocity.x = lerp(velocity.x, target_velocity.x, acceleration * delta)
        velocity.z = lerp(velocity.z, target_velocity.z, acceleration * delta)
    else:
        velocity.x = lerp(velocity.x, 0.0, deceleration * delta)
        velocity.z = lerp(velocity.z, 0.0, deceleration * delta)

    # Apply gravity to vertical movement
    velocity.y = gravity_velocity

    # Move the character
    move_and_slide()

    # Handle animation states
    update_animation_state()

func update_animation_state():
    var input_value = input_vector.length()
    var new_state: PlayerState

    # Determine the new state
    if is_jumping:
        new_state = PlayerState.JUMPING
    elif not is_on_floor() and gravity_velocity < 0:
        new_state = PlayerState.FALLING
    elif is_on_floor():
        if input_value < 0.1:
            new_state = PlayerState.IDLE
        else:
            new_state = PlayerState.RUNNING

    # Only update animation if state has changed
    if new_state != current_state:
        current_state = new_state
        match current_state:
            PlayerState.IDLE:
                model.idle()
            PlayerState.RUNNING:
                model.run()
            PlayerState.FALLING:
                model.fall()
            PlayerState.JUMPING:
                model.jump()

After binding some inputs, here we go:

Another aspect worth mentioning is that lighting a scene can be overwhelming for a beginner. There are so many fancy acronyms: SSR, SSAO, SSIL, SDFGI, and so on. I just wanted a blue sky, a yellow sun, and a nice atmosphere. How do I achieve that? That’s when I stumbled upon the lighting genius, also known as passive star, and in particular, this article: How to Light Scenes. Although I’m far from fully understanding what I’m doing, I started getting renders I was happy with for now.

I also picked up two addons from the Godot Engine assets: Prototype CSGs and Phantom Camera. They work well and save me time on tasks I didn’t want to focus on just yet.

I embarked on another side quest regarding assets to block out environments, where I picked up assets from Syntystore and converted them from Unity to Godot. But I’ll talk about this in another article.

This time, for real, my next goal is to block out File City!