Welcome to part 3 of the series where I port a popular Godot tutorial to Defold
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
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
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:
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
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 :)!
Video 10 - Sending messages and instancing things
First lets make some grass!
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:
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!
And thats it for part 3, see you again soon!