Create behaviour tree implementation

master
rubenwardy 2020-04-06 21:59:13 +01:00
parent dc4271cc7e
commit aa2c86c580
11 changed files with 452 additions and 66 deletions

10
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,10 @@
{
"spellright.language": [
"en_GB"
],
"spellright.documentTypes": [
"markdown",
"latex",
"plaintext"
]
}

View File

@ -48,6 +48,24 @@ A session is a running game. Units and countries from different sessions cannot
* `join(player) --> country` - Player joins session, new country created.
* `set_player_country(player, country_id) --> boolean`
### Behaviour Trees
Conquer uses a simple [behaviour tree](https://en.wikipedia.org/wiki/Behavior_tree_(artificial_intelligence,_robotics_and_control)
implementation to drive the unit actions.
Behaviour trees can be used to compose complex behaviour,
but their use in conquer is simple.
Composite nodes:
* `FickleSequence:new(child1, child2)` - runs each child every tick.
* `Sequence:new(child1, child2)` - runs each child once, remembering the last ran child.
Action nodes:
* `FollowPath:new(path, object_animations)` - follow a path.
* `MoveToObject:new(target, object_animations)` - move to an object.
* `MeleeAttack:new(target, object_animations)` - attack.
### Utilities
* `conquer.class() --> table` - makes a Lua class.

View File

@ -121,11 +121,13 @@ function UnitEntity:get_staticdata()
})
end
assert(conquer.behaviour.states.RUNNING == "running")
function UnitEntity:on_step(delta)
if self.action then
self.action:on_step(delta)
if self.action:is_complete() then
if self.action:get_state() ~= conquer.behaviour.states.RUNNING then
self.action = nil
end
end

216
behaviour.lua Normal file
View File

@ -0,0 +1,216 @@
local states = {
RUNNING = "running",
FAILED = "failed",
SUCCESS = "success",
}
conquer.behaviour = {
states = states,
}
--
-- Behaviour Tree Primitives
--
-- A fickle sequence runs each child every tick
local FickleSequence = conquer.class()
conquer.behaviour.FickleSequence = FickleSequence
function FickleSequence:constructor(...)
self.children = { ... }
self.state = states.RUNNING
end
function FickleSequence:get_state()
return self.state
end
function FickleSequence:on_step(delta)
self.state = states.RUNNING
for i=1, #self.children do
local child = self.children[i]
child:on_step(delta)
local state = child:get_state()
if state == states.FAILED then
self.state = states.FAILED
return
elseif state == states.RUNNING then
return
end
end
self.state = states.SUCCESS
end
-- A sequence will remember which was the last to run
local Sequence = conquer.class()
conquer.behaviour.Sequence = Sequence
function Sequence:constructor(...)
self.failed = false
self.children = {...}
end
function Sequence:get_state()
if self.failed then
return states.FAILED
elseif #self.children == 0 then
return states.SUCCESS
else
return states.RUNNING
end
end
function Sequence:on_step(delta)
local child = self.children[1]
if child then
child:on_step(delta)
local state = child:get_state()
if state == states.SUCCESS then
table.remove(self.children, 1)
elseif state == states.FAILED then
self.failed = true
return
end
end
end
--
-- Implementations
--
local FollowPath = conquer.class()
conquer.behaviour.FollowPath = FollowPath
function FollowPath:constructor(path, animations)
self.path = {}
for i=1, #path do
self.path[i] = vector.add(path[i], vector.new(0, -0.49, 0))
end
self.path_i = 1
self.animation = nil
self.animations = animations
end
function FollowPath:get_state()
return self.path and states.RUNNING or states.SUCCESS
end
function FollowPath:on_step(delta)
if not self.path then
return
end
local next = self.path[self.path_i]
if not next then
self.object:set_pos(self.path[#self.path])
self.object:set_velocity(vector.new())
self:set_walking(false)
self.path = nil
return
end
local from = self.object:get_pos()
local distance = vector.distance(from, next)
if distance < 0.1 then
self.path_i = self.path_i + 1
self:on_step(delta)
return
end
local step = vector.multiply(vector.normalize(vector.subtract(next, from)), 1)
self.object:set_velocity(step)
local target = vector.add(from, vector.multiply(step, delta))
self.object:move_to(target, true)
self.object:set_yaw(math.atan2(step.z, step.x) - math.pi / 2)
self:set_walking(true)
end
function FollowPath:set_walking(walking)
if self.animation ~= nil and self.animation == walking then
return
end
self.animation = walking
self.object:set_animation(walking and self.animations.walk or self.animations.stand, 30, 0)
end
local MoveToObject = conquer.class()
conquer.behaviour.MoveToObject = MoveToObject
function MoveToObject:constructor(target, animations)
self.target = target
self.animations = animations
self.child = nil
self.last_pos = nil
self.failed = false
end
function MoveToObject:get_state()
if self.failed or not self.target or not self.target:get_luaentity() then
return states.failed
elseif vector.distance(self.object:get_pos(), self.target:get_pos()) < 2 then
return states.success
else
return states.running
end
end
function MoveToObject:on_step(delta)
local target_pos = self.target:get_pos()
if not self.last_pos or
not vector.equals(vector.round(self.last_pos), vector.round(target_pos)) then
local path = minetest.find_path(self.object:get_pos(), target_pos, 20, 1, 1)
if not path then
self.failed = true
return
end
local unit_type = self.object:get_luaentity():get_unit_type()
self.child = conquer.behaviour.FollowPath:new(path, unit_type.animations)
self.last_pos = target_pos
end
if self.child then
self.child:on_step(self, delta)
end
end
local MeleeAttack = conquer.class()
conquer.behaviour.MeleeAttack = MeleeAttack
function MeleeAttack:constructor(target)
self.target = target
end
function MeleeAttack:get_state()
if self.target and self.target:get_luaentity() then
return states.RUNNING
else
return states.SUCCESS
end
end
function MeleeAttack:on_step(delta) --luacheck: ignore
end

View File

@ -1,61 +0,0 @@
conquer.actions = {}
local FollowPath = conquer.class()
conquer.actions.FollowPath = FollowPath
function FollowPath:constructor(path, animations)
self.path = {}
for i=1, #path do
self.path[i] = vector.add(path[i], vector.new(0, -0.49, 0))
end
self.path_i = 1
self.animation = nil
self.animations = animations
end
function FollowPath:is_complete()
return not self.path
end
function FollowPath:on_step(delta)
if not self.path then
return
end
local next = self.path[self.path_i]
if not next then
self.object:set_pos(self.path[#self.path])
self.object:set_velocity(vector.new())
self:set_walking(false)
self.path = nil
return
end
local from = self.object:get_pos()
local distance = vector.distance(from, next)
if distance < 0.1 then
self.path_i = self.path_i + 1
self:on_step(delta)
return
end
local step = vector.multiply(vector.normalize(vector.subtract(next, from)), 1)
self.object:set_velocity(step)
local target = vector.add(from, vector.multiply(step, delta))
self.object:move_to(target, true)
self.object:set_yaw(math.atan2(step.z, step.x) - math.pi / 2)
self:set_walking(true)
end
function FollowPath:set_walking(walking)
if self.animation ~= nil and self.animation == walking then
return
end
self.animation = walking
self.object:set_animation(walking and self.animations.walk or self.animations.stand, 30, 0)
end

View File

@ -5,7 +5,7 @@ assert(minetest.features.pathfinder_works, "This mod requires Minetest 5.2 or la
dofile(minetest.get_modpath("conquer") .. "/utils.lua")
dofile(minetest.get_modpath("conquer") .. "/Session.lua")
dofile(minetest.get_modpath("conquer") .. "/api.lua")
dofile(minetest.get_modpath("conquer") .. "/entity_actions.lua")
dofile(minetest.get_modpath("conquer") .. "/behaviour.lua")
dofile(minetest.get_modpath("conquer") .. "/UnitEntity.lua")
dofile(minetest.get_modpath("conquer") .. "/wands.lua")
dofile(minetest.get_modpath("conquer") .. "/nodes.lua")

View File

@ -1,7 +1,7 @@
_G.conquer = {}
dofile("tests/mocks.lua")
dofile("utils.lua")
dofile("tests/mocks.lua")
dofile("Session.lua")
dofile("api.lua")

180
tests/behaviour_spec.lua Normal file
View File

@ -0,0 +1,180 @@
_G.conquer = {}
dofile("utils.lua")
local mocks = dofile("tests/mocks.lua")
dofile("behaviour.lua")
local MockBehaviour = mocks.MockBehaviour
describe("FickleSequence", function()
local FickleSequence = conquer.behaviour.FickleSequence
assert(FickleSequence)
it("runs in order", function()
local a = MockBehaviour:new()
local b = MockBehaviour:new()
local function reset()
a.ran = false
b.ran = false
end
local seq = FickleSequence:new(a, b)
assert.equals("running", seq:get_state())
assert.is_false(a.ran)
assert.is_false(b.ran)
seq:on_step(0.1)
assert.is_true(a.ran)
assert.is_false(b.ran)
assert.equals("running", seq:get_state())
reset()
seq:on_step(0.1)
assert.is_true(a.ran)
assert.is_false(b.ran)
assert.equals("running", seq:get_state())
a.state = "success"
reset()
seq:on_step(0.1)
assert.is_true(a.ran)
assert.is_true(b.ran)
assert.equals("running", seq:get_state())
reset()
seq:on_step(0.1)
assert.is_true(a.ran)
assert.is_true(b.ran)
assert.equals("running", seq:get_state())
b.state = "success"
reset()
seq:on_step(0.1)
assert.is_true(a.ran)
assert.is_true(b.ran)
assert.equals("success", seq:get_state())
end)
it("handles failed", function()
local a = MockBehaviour:new()
local b = MockBehaviour:new()
a.state = "success"
local function reset()
a.ran = false
b.ran = false
end
local seq = FickleSequence:new(a, b)
assert.equals("running", seq:get_state())
assert.is_false(a.ran)
assert.is_false(b.ran)
seq:on_step(0.1)
assert.is_true(a.ran)
assert.is_true(b.ran)
assert.equals("running", seq:get_state())
b.state = "failed"
reset()
seq:on_step(0.1)
assert.is_true(a.ran)
assert.is_true(b.ran)
assert.equals("failed", seq:get_state())
end)
end)
describe("Sequence", function()
local Sequence = conquer.behaviour.Sequence
assert(Sequence)
it("runs in order", function()
local a = MockBehaviour:new()
local b = MockBehaviour:new()
local function reset()
a.ran = false
b.ran = false
end
local seq = Sequence:new(a, b)
assert.equals("running", seq:get_state())
assert.is_false(a.ran)
assert.is_false(b.ran)
seq:on_step(0.1)
assert.is_true(a.ran)
assert.is_false(b.ran)
assert.equals("running", seq:get_state())
reset()
seq:on_step(0.1)
assert.is_true(a.ran)
assert.is_false(b.ran)
assert.equals("running", seq:get_state())
a.state = "success"
reset()
seq:on_step(0.1)
assert.is_true(a.ran)
assert.is_false(b.ran)
assert.equals("running", seq:get_state())
reset()
seq:on_step(0.1)
assert.is_false(a.ran)
assert.is_true(b.ran)
assert.equals("running", seq:get_state())
b.state = "success"
reset()
seq:on_step(0.1)
assert.is_false(a.ran)
assert.is_true(b.ran)
assert.equals("success", seq:get_state())
end)
it("handles failed", function()
local a = MockBehaviour:new()
local b = MockBehaviour:new()
a.state = "success"
local function reset()
a.ran = false
b.ran = false
end
local seq = Sequence:new(a, b)
assert.equals("running", seq:get_state())
assert.is_false(a.ran)
assert.is_false(b.ran)
seq:on_step(0.1)
assert.is_true(a.ran)
assert.is_false(b.ran)
assert.equals("running", seq:get_state())
reset()
seq:on_step(0.1)
assert.is_false(a.ran)
assert.is_true(b.ran)
assert.equals("running", seq:get_state())
b.state = "failed"
reset()
seq:on_step(0.1)
assert.is_false(a.ran)
assert.is_true(b.ran)
assert.equals("failed", seq:get_state())
end)
end)

View File

@ -22,3 +22,24 @@ _G.vector = {
}
end,
}
local MockBehaviour = conquer.class()
function MockBehaviour:constructor()
self.state = "running"
self.ran = false
end
function MockBehaviour:get_state()
return self.state
end
function MockBehaviour:on_step()
self.ran = true
end
return {
MockBehaviour = MockBehaviour
}

View File

@ -1,7 +1,7 @@
_G.conquer = {}
dofile("tests/mocks.lua")
dofile("utils.lua")
dofile("tests/mocks.lua")
dofile("Session.lua")
local Session = conquer.Session

View File

@ -61,7 +61,7 @@ conquer.register_wand("conquer:move", {
end
local unit_type = unit:get_luaentity():get_unit_type()
local action = conquer.actions.FollowPath:new(path, unit_type.animations)
local action = conquer.behaviour.FollowPath:new(path, unit_type.animations)
unit:get_luaentity():set_action(action)
end,
})