Welcome to part 3 of the series where I port a popular Godot tutorial to Defold

Check out the prior posts to see how we got here:

Last time we ended on a low note because I was not quite able to make Defolds standard Tilemaps work so we start this time by "fixing" this and by fixing I mean using LDtk instead

001

Since neither Defold nor LDtk support each other I simply exported the images in the "Super simple export format" which essentially just renders the map as an image (plus some JSON files), so we took this and just add some sprites in Defold

002

and now we have a nice tilemap :).

Next step is cleaning up the animation code which sadly does not work quite as nice as in the Godot version just yet, see for instance:

003

This is partly because Godot is using an animation tree which is a vastly more sophisticated solution than what I currently have set up (press button use animation, reset when no inputs are pressed) so we just make our own!

-- file: modules/animation_tree.lua
 
local xvmath = require("modules.xvmath")
 
local AnimationTree = {}
 
---@class AnimationTree
---@field current_animation string|nil
---@field current_blenspace string|nil
---@field blendspaces table<string, Blendspace>
---@field blendspace fun(self: AnimationTree, name: string, animations: table<integer, BlendspaceAnimation>): nil
---@field play fun(self: AnimationTree, name: string): nil
---@field update fun(self: AnimationTree, value: vector, callback: fun(name: string)): nil
 
---@class Blendspace
---@field name string
---@field animations table<integer, BlendspaceAnimation>
 
---@class BlendspaceAnimation
---@field animation string
---@field value vector3
 
---@return AnimationTree
function AnimationTree.new()
    local state = {
        current_animation = nil,
        current_blenspace = nil,
        last_value = xvmath.zero(),
        blendspaces = {},
    }
    return setmetatable(state, { __index = AnimationTree })
end
 
---Add a blend space
---@param name string
---@param animations table<integer, BlendspaceAnimation>
function AnimationTree:blendspace(name, animations)
    self.blendspaces[name] = animations
end
 
---Set current animation
---@param name any
function AnimationTree:play(name)
    self.current_blenspace = name
end
 
---Update blend position
---@param value vector3
function AnimationTree:update(value, on_animation_changed)
    local closest_anim = nil
    local min_dist = 99999999
 
    if value == xvmath.zero() then
        value = self.last_value
    end
 
    for _, animation in ipairs(self.blendspaces[self.current_blenspace]) do
        local dist = xvmath.distance(value, animation.value)
 
        if dist < min_dist then
            min_dist = dist
            closest_anim = animation.animation
        end
    end
 
    if not closest_anim then
        return
    end
 
    if on_animation_changed and closest_anim ~= self.current_animation then
        self.last_value = value
        self.current_animation = closest_anim
        on_animation_changed(closest_anim)
    end
end
 
return AnimationTree

next we just initialize it in the player controller:

function init(self)
    -- ...
    self.anim_tree = AnimationTree.new()
    self.anim_tree:blendspace("idle", {
        { animation = "idle_up",    value = xvmath.up() },
        { animation = "idle_down",  value = xvmath.down() },
        { animation = "idle_left",  value = xvmath.left() },
        { animation = "idle_right", value = xvmath.right() },
    })
    self.anim_tree:blendspace("walk", {
        { animation = "walk_up",    value = xvmath.up() },
        { animation = "walk_down",  value = xvmath.down() },
        { animation = "walk_left",  value = xvmath.left() },
        { animation = "walk_right", value = xvmath.right() },
    })
    self.anim_tree:play("idle")
end

and then just call the update function in update...

function update(self, dt)
    self.anim_tree:update(self.input_vector, on_animation_changed)
    -- ...
end
 
-- ...
 
---@param animation string
function on_animation_changed(animation)
    sprite.play_flipbook("#sprite", hash(animation))
end

aaand we have a somewhat nice way to animate sprites :)

Video 8 - Collisions with the world!

Since we are using an external tilemap this is theoretically a bit harder than just using the internal tilemap which would have supported collisions, I opted to do one fairly quick and dirty solution which is just adding collision shapes manually to where I want them

004

Although if we would want to do this programmatically we have the locations of the cliffs available via a CSV file and/or some other ways.

I also refrained from investing too much time into setting up the auto tile feature here as LDtk does not seem to play along nicely with tilesets of different tile sizes (Heartbeast uses 32x32 for the cliffs)

Video 9 - Attacking!

First we need to define our states:

local STATE_MOVE = 0
local STATE_ATTACK = 1
local STATE_ROLL = 2

and set the initial state to be MOVE in our init function

function init(self)
    -- ...
    self.state = STATE_MOVE

while we are not in move we do not quite need the current input handling code so we just return early when not moving

function on_input(self, action_id, action)
    if not self.state == STATE_MOVE then
        return
    end
    -- ...
end

Next lets extract the current code inside update into its own function as this is basically already what the move state is supposed to be

---@param self PlayerController
---@param dt float
function update(self, dt)
    move_state(self, dt)
end
 
---@param self PlayerController
---@param dt float
function move_state(self, dt)
    self.anim_tree:update(self.input_vector, on_animation_changed)
 
    if self.input_vector == xvmath.zero() then
        --reset_animation(self)
        self.velocity = xvmath.move_toward(self.velocity, xvmath.zero(), FRICTION * dt)
        self.anim_tree:play("idle")
    else
        self.velocity = xvmath.move_toward(self.velocity, self.input_vector * MAX_SPEED, ACCELERATION * dt)
        self.anim_tree:play("walk")
    end
 
    move(self.velocity * dt)
end

now lets also create functions for the other states and also to differentiate between them in update

---@param self PlayerController
---@param dt float
function update(self, dt)
    if self.state == STATE_MOVE then
        move_state(self, dt)
    elseif self.state == STATE_ATTACK then
        attack_state(self, dt)
    elseif self.state == STATE_ROLL then
        roll_state(self, dt)
    end
end

inside on_input we now want to check if the attack action has been pressed and switch to the attack state if that is true:

    if action.pressed and action_id == hash("attack") then
        self.state = STATE_ATTACK
    end

If we just play the animation in our attack state we will get hit by our old friend: Calling sprite.play_book multiple times means resetting the animation in Defold which in Godot would do nothing until the animation is finished, to solve this we add a guard called "animation_playing" which we set to true and then after the animation is finished for which Defold offers a nice callback, we unset it and return the state to STATE_MOVE

---@param self PlayerController
---@param dt float
function attack_state(self, dt)
    if self.animation_playing then
        return
    end
 
    self.animation_playing = true
 
    sprite.play_flipbook("#sprite", hash("attack_right"), function(self, message_id)
        if message_id ~= hash("animation_done") then
            return
        end
 
        -- we are no longer animation so we dont need the guard anymore
        self.animation_playing = false
 
        -- revert animation back to idle
        sprite.play_flipbook("#sprite", hash("idle_right"))
 
        self.state = STATE_MOVE
    end)
end

next to make this work with all directions and our animation tree we had to implement a small hack

---@param self PlayerController
---@param dt float
function attack_state(self, dt)
    if self.animation_playing then
        return
    end
 
    self.animation_playing = true
    self.velocity = xvmath.zero()
 
    self.anim_tree:play("attack")
 
    self.anim_tree:update(self.input_vector, function(name)
        sprite.play_flipbook("#sprite", hash(name), function(self, message_id)
            if message_id ~= hash("animation_done") then
                return
            end
 
            -- we are no longer animation so we dont need the guard anymore
            self.animation_playing = false
 
            self.anim_tree:play("idle")
            self.state = STATE_MOVE
        end)
    end)
end

we misuse our animation tree a bit here, by changing the state and calling update the animation will change to whatever attack animation fits the current input vector and therefore we get the correct direction.

And thats it for video 9! We can now run around and attack in all directions :)!

005

Video 10 - Sending messages and instancing things

First lets make some grass!

006

Next we need to add the grass effect which is a simple new game object with a sprite and a new script which simply plays the animation and then deletes itself again

function init()
    sprite.play_flipbook("#sprite", hash("anim"), function(_self, message_id, _message, _sender)
        if message_id ~= hash("animation_done") then
            return
        end
 
        go.delete()
    end)
end

lastly all we need is a way to spawn and trigger the animation for that we set up a game object with a factory in the level:

007

and add the following script to the grass game object:

function init(_self)
	msg.post(".", "acquire_input_focus")
end
 
function on_input(self, action_id, action)
	if action.pressed and action_id == hash("attack") then
		spawn_animation()
		go.delete()
	end
end
 
function spawn_animation()
	factory.create("/grass_effect_spawner#factory", go.get_position())
end

aka for now we destroy all grass when the player hits the attack action!

008

And thats it for part 3, see you again soon!