├── .luacheckrc ├── .travis.yml ├── README.md ├── debug_graph.lua ├── spec.lua └── ygg.lua /.luacheckrc: -------------------------------------------------------------------------------- 1 | max_line_length = false 2 | allow_defined = false -- Do NOT allow implicitly defined globals. 3 | allow_defined_top = false -- Do NOT allow implicitly defined globals. 4 | 5 | files = { 6 | ['debug_graph.lua'] = { 7 | std = 'luajit+love', 8 | }, 9 | ['ygg.lua'] = { 10 | std = 'luajit', 11 | }, 12 | ['spec.lua'] = { 13 | std = 'luajit+busted', 14 | }, 15 | } 16 | 17 | exclude_files = { 18 | 'lua_install/*', -- CI: hererocks 19 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | 4 | env: 5 | - LUA="lua=5.1" 6 | - LUA="luajit=2.0" 7 | - LUA="luajit=2.1" 8 | 9 | before_install: 10 | - pip install hererocks 11 | - hererocks lua_install -r^ --$LUA 12 | - export PATH=$PATH:$PWD/lua_install/bin 13 | 14 | install: 15 | - luarocks install busted 16 | - luarocks install luacheck 17 | - luarocks install luacov 18 | 19 | script: 20 | - busted --verbose --coverage spec.lua 21 | - luacheck . 22 | 23 | after_success: 24 | - luacov 25 | - bash <(curl -s https://codecov.io/bash) 26 | 27 | notifications: 28 | email: 29 | on_success: change 30 | on_failure: always 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Ygg 2 | === 3 | 4 | [![Build Status](https://travis-ci.org/oniietzschan/ygg.svg?branch=master)](https://travis-ci.org/oniietzschan/ygg) 5 | [![Codecov](https://codecov.io/gh/oniietzschan/ygg/branch/master/graph/badge.svg)](https://codecov.io/gh/oniietzschan/ygg) 6 | ![Lua](https://img.shields.io/badge/Lua-JIT%2C%205.1-blue.svg) 7 | ![Public Domain](https://img.shields.io/badge/license-public%20domain-blue) 8 | 9 | Behaviour trees in Lua. 10 | 11 | ![A High Resolution Photo Of A 53-Year-Old Man Manipulating His Sphincter](https://i.ibb.co/kQCdwm0/2020-02-01-behaviour-tree.gif) 12 | 13 | ```lua 14 | local isHungry = Ygg(function(this) 15 | return this.hunger >= 50 16 | end) 17 | 18 | local isSleepy = Ygg(function(this) 19 | return this.tiredness >= 100 20 | end) 21 | 22 | local eat = Ygg(function(this, dt) 23 | if this.hunger == 0 then 24 | return false 25 | end 26 | this.hunger = math.max(0, this.hunger - 10 * dt) 27 | return (this.hunger == 0) and true or nil 28 | end) 29 | 30 | local sleep = Ygg(function(this, dt) 31 | if this.tiredness == 0 then 32 | return false 33 | end 34 | this.tiredness = math.max(0, this.tiredness - 5 * dt) 35 | return (this.tiredness == 0) and true or nil 36 | end) 37 | 38 | local idle = Ygg(function(this, dt) 39 | this.hunger = this.hunger + dt 40 | this.tiredness = this.tiredness + dt 41 | return true 42 | end) 43 | 44 | local tree = Ygg.selector() 45 | :add( 46 | Ygg.sequence() 47 | :add(isHungry) 48 | :add(eat) 49 | ) 50 | :add( 51 | Ygg.sequence() 52 | :add(isSleepy) 53 | :add(sleep) 54 | ) 55 | :add(idle) 56 | 57 | local runner = Ygg.run(tree) 58 | 59 | local entity = { 60 | hunger = 25, 61 | tiredness = 0, 62 | } 63 | 64 | for _ = 1, 10 do 65 | print(("Hunger: %d, Tired: %d"):format(entity.hunger, entity.tiredness)) 66 | local dt = 1 67 | -- :update() arguments will be passed into action functions. 68 | runner:update(entity, dt) 69 | end 70 | ``` 71 | 72 | Documentation 73 | ------------- 74 | 75 | Coming soon???? You're on your own for now, chief. 76 | 77 | Debug Graph 78 | ----------- 79 | 80 | Included in the repository is an optional file `debug_graph.lua` which you can use to help visualize your tree and work out issues. You can use it like so: 81 | 82 | ```lua 83 | 84 | local YggDebugGraph = require 'debug_graph' 85 | local graph = YggDebugGraph(yggTreeRunner) 86 | 87 | function love.draw() 88 | local x, y = 10, 10 89 | graph:draw(x, y) 90 | end 91 | ``` 92 | -------------------------------------------------------------------------------- /debug_graph.lua: -------------------------------------------------------------------------------- 1 | local Graph = {} 2 | 3 | function Graph:_new(runner) 4 | self._runner = runner 5 | self._tree = {} 6 | self:_expand(self._tree, self._runner._action) 7 | return self 8 | end 9 | 10 | function Graph:_expand(branch, action) 11 | local descendants = 0 12 | branch.action = action 13 | branch.len = 0 14 | 15 | if action.class == 'ACTION' then 16 | branch.name = action.name 17 | branch.descendants = 0 18 | 19 | else 20 | if action.class == 'SELECTOR' then 21 | branch.name = 'Sel: ' .. action.name 22 | elseif action.class == 'SEQUENCE' then 23 | branch.name = 'Seq: ' .. action.name 24 | end 25 | branch.len = #action._actions 26 | for i = 1, branch.len do 27 | branch[i] = {} 28 | descendants = descendants + self:_expand(branch[i], action._actions[i]) 29 | end 30 | branch.descendants = descendants 31 | end 32 | 33 | return descendants + 1 34 | end 35 | 36 | local lg = love.graphics 37 | 38 | local PAD_H = 2 39 | local PAD_V = 1 40 | local INDENT = 15 41 | local COLOR_NODE_INACTIVE = {0.9, 0.9, 0.9, 0.75} 42 | local COLOR_NODE_ACTIVE = {1.0, 1.0, 1.0, 1} 43 | local COLOR_TEXT = {0, 0, 0, 1} 44 | 45 | function Graph:draw(x, y) 46 | lg.push('all') 47 | self:_drawNode(self._tree, x, y, true) 48 | lg.pop() 49 | end 50 | 51 | function Graph:_drawNode(branch, x, y, parentActive) 52 | local font = lg.getFont() 53 | local w = font:getWidth(branch.name) + (PAD_H * 2) 54 | local h = font:getHeight() + (PAD_V * 2) 55 | 56 | -- Draw current node. 57 | local active = parentActive and self:_isActionActive(branch.action) 58 | lg.setColor(active and COLOR_NODE_ACTIVE or COLOR_NODE_INACTIVE) 59 | lg.rectangle('fill', x, y, w, h) 60 | lg.setColor(COLOR_TEXT) 61 | lg.print(branch.name, x + PAD_H, y + PAD_V) 62 | 63 | -- Draw leaves. 64 | x = x + INDENT 65 | for i = 1, branch.len do 66 | local prevDescendants = 1 67 | if i >= 2 then 68 | prevDescendants = branch[i - 1].descendants + 1 69 | end 70 | y = y + (prevDescendants * (h + PAD_V)) 71 | self:_drawNode(branch[i], x, y, active) 72 | end 73 | end 74 | 75 | function Graph:_isActionActive(action) 76 | local runner = self._runner 77 | while true do 78 | if runner._action == action then 79 | return true 80 | 81 | elseif runner._next == nil then 82 | local actions = runner._action._actions 83 | return actions[runner.index] == action 84 | end 85 | 86 | runner = runner._next 87 | end 88 | end 89 | 90 | local GraphMT = { 91 | __index = Graph, 92 | } 93 | 94 | return function(...) 95 | return setmetatable({}, GraphMT) 96 | :_new(...) 97 | end 98 | -------------------------------------------------------------------------------- /spec.lua: -------------------------------------------------------------------------------- 1 | require 'busted' 2 | 3 | local Ygg = require 'ygg' 4 | 5 | describe('Ygg:', function() 6 | it('Basic functionality', function() 7 | local isHungry = Ygg('isHungry', function(this) 8 | return this.hunger >= 50 9 | end) 10 | local isSleepy = Ygg('isSleepy', function(this) 11 | return this.tiredness >= 100 12 | end) 13 | local eat = Ygg('eat', function(this) 14 | this.state = 'eating' 15 | this.hunger = math.max(0, this.hunger - 25) 16 | return (this.hunger == 0) and true or nil 17 | end) 18 | local sleep = Ygg('sleep', function(this) 19 | this.state = 'sleeping' 20 | this.tiredness = math.max(0, this.tiredness - 30) 21 | return (this.tiredness == 0) and true or nil 22 | end) 23 | local idle = Ygg('idle', function(this) 24 | this.state = 'idle' 25 | this.hunger = this.hunger + 10 26 | this.tiredness = this.tiredness + 10 27 | return true 28 | end) 29 | 30 | local tree = Ygg.selector('root') 31 | :add( 32 | Ygg.sequence('hunger sequence') 33 | :add(isHungry) 34 | :add(eat) 35 | ) 36 | :add( 37 | Ygg.sequence('sleep sequence') 38 | :add(isSleepy) 39 | :add(sleep) 40 | ) 41 | :add(idle) 42 | 43 | local runner = Ygg.run(tree) 44 | local entity = { 45 | state = '', 46 | hunger = 30, 47 | tiredness = 70, 48 | } 49 | 50 | local expectedResults = { 51 | {state = 'idle', hunger = 40, tiredness = 80}, 52 | {state = 'idle', hunger = 50, tiredness = 90}, 53 | {state = 'eating', hunger = 25, tiredness = 90}, 54 | {state = 'eating', hunger = 0, tiredness = 90}, 55 | {state = 'idle', hunger = 10, tiredness = 100}, 56 | {state = 'sleeping', hunger = 10, tiredness = 70}, 57 | {state = 'sleeping', hunger = 10, tiredness = 40}, 58 | {state = 'sleeping', hunger = 10, tiredness = 10}, 59 | {state = 'sleeping', hunger = 10, tiredness = 0}, 60 | {state = 'idle', hunger = 20, tiredness = 10}, 61 | } 62 | for _, expected in ipairs(expectedResults) do 63 | runner:update(entity) 64 | -- print(("State: %8s, Hunger: %3d, Tired: %3d"):format(entity.state, entity.hunger, entity.tiredness)) 65 | assert.same(expected, entity) 66 | end 67 | end) 68 | 69 | it('Should be able to have a Action in Selector return nil (although this is unusual, maybe?)', function() 70 | local tryEat = Ygg('tryEat', function(this) 71 | this.state = 'eating' 72 | if this.hunger == 0 then 73 | return false 74 | end 75 | this.hunger = math.max(0, this.hunger - 25) 76 | return nil 77 | end) 78 | local idle = Ygg('idle', function(this) 79 | this.state = 'idle' 80 | return true 81 | end) 82 | 83 | local tree = Ygg.selector('root') 84 | :add(tryEat) 85 | :add(idle) 86 | 87 | local runner = Ygg.run(tree) 88 | local entity = { 89 | state = 'idle', 90 | hunger = 60, 91 | } 92 | 93 | local expectedResults = { 94 | {state = 'eating', hunger = 35}, 95 | {state = 'eating', hunger = 10}, 96 | {state = 'eating', hunger = 0}, 97 | {state = 'idle', hunger = 0}, 98 | } 99 | for _, expected in ipairs(expectedResults) do 100 | runner:update(entity) 101 | -- print(("State: %6s, Hunger: %2d"):format(entity.state, entity.hunger)) 102 | assert.same(expected, entity) 103 | end 104 | end) 105 | 106 | it('Selector should restart if any sub-nodes succeed, including sequence.', function() 107 | local willAlwaysSucceedOne = Ygg('addIdol', function(this) 108 | this.expected = this.expected + 1 109 | return true 110 | end) 111 | local willAlwaysSucceedTen = Ygg('addIdol', function(this) 112 | this.expected = this.expected + 10 113 | return true 114 | end) 115 | local shouldNotBeExecuted = Ygg('willFail', function(this) 116 | this.unexpected = this.unexpected + 1 117 | return true 118 | end) 119 | 120 | local tree = Ygg.selector('root') 121 | :add( 122 | Ygg.sequence('success seq') 123 | :add(willAlwaysSucceedOne) 124 | :add(willAlwaysSucceedTen) 125 | ) 126 | :add(shouldNotBeExecuted) 127 | 128 | local runner = Ygg.run(tree) 129 | local entity = { 130 | expected = 0, 131 | unexpected = 0, 132 | } 133 | 134 | local expectedResults = { 135 | {expected = 11, unexpected = 0}, 136 | {expected = 22, unexpected = 0}, 137 | } 138 | for _, expected in ipairs(expectedResults) do 139 | runner:update(entity) 140 | assert.same(expected, entity) 141 | end 142 | 143 | shouldNotBeExecuted.func({unexpected = -1}) -- Test coverage lololol 144 | end) 145 | 146 | it('Should reset to first index after Selector succeeds', function() 147 | local hasPlate = Ygg('hasPlate', function(this) 148 | return this.plates >= 1 149 | end) 150 | local getFood = Ygg('getFood', function(this) 151 | this.food = this.food + 1 152 | return true 153 | end) 154 | local getPlate = Ygg('getPlate', function(this) 155 | this.plates = this.plates + 1 156 | return true 157 | end) 158 | 159 | local tree = Ygg.selector('root') 160 | :add( 161 | Ygg.sequence('food seq') 162 | :add(hasPlate) 163 | :add(getFood) 164 | ) 165 | :add( 166 | Ygg.sequence('plate seq') 167 | :add(getPlate) 168 | ) 169 | 170 | local runner = Ygg.run(tree) 171 | local entity = { 172 | food = 0, 173 | plates = 0, 174 | } 175 | 176 | local expectedResults = { 177 | {food = 0, plates = 1}, 178 | {food = 1, plates = 1}, 179 | {food = 2, plates = 1}, 180 | {food = 3, plates = 1}, 181 | } 182 | for _, expected in ipairs(expectedResults) do 183 | runner:update(entity) 184 | -- print(("Food: %d, Plates: %d"):format(entity.food, entity.plates)) 185 | assert.same(expected, entity) 186 | end 187 | end) 188 | 189 | it('Should reset to first index if Selector within Sequence fails', function() 190 | local addIdol = Ygg('addIdol', function(this) 191 | this.idols = this.idols + 1 192 | return true 193 | end) 194 | local willFail = Ygg('willFail', function() 195 | return false 196 | end) 197 | 198 | local tree = Ygg.sequence('root') 199 | :add(addIdol) 200 | :add( 201 | Ygg.selector('fail selector') 202 | :add(willFail) 203 | :add(willFail) 204 | :add(willFail) 205 | ) 206 | 207 | local runner = Ygg.run(tree) 208 | local entity = { 209 | idols = 0, 210 | } 211 | 212 | local expectedResults = { 213 | {idols = 1}, 214 | {idols = 2}, 215 | {idols = 3}, 216 | } 217 | for _, expected in ipairs(expectedResults) do 218 | runner:update(entity) 219 | -- print(("Idols: %d"):format(entity.idols)) 220 | assert.same(expected, entity) 221 | end 222 | end) 223 | 224 | it('Should be able to create actions without a name', function() 225 | local action = Ygg(function() end) 226 | assert.same('', action.name) 227 | end) 228 | 229 | it('Invalid scripts should relay error message', function() 230 | local expectedError = "action function must be a function, got: nil" 231 | assert.has_error(function() Ygg(11) end, expectedError) 232 | end) 233 | end) 234 | -------------------------------------------------------------------------------- /ygg.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | 3 | Ygg v0.0.0 4 | ========== 5 | 6 | Behaviour trees by shru. 7 | 8 | https://github.com/oniietzschan/ygg 9 | 10 | LICENSE 11 | ------- 12 | 13 | shru-chan hereby dedicates this source code and associated documentation 14 | (the "App") to the public domain. shru makes this dedication for the 15 | benefit of the Gamers everywhere and to the detriment of trolls and bullies. 16 | Anyone is free to copy, modify, publish, use, sell, distribute, recite in a 17 | spooky voice, or fax the App by any means they desire, so long as they 18 | adhere to one condition: 19 | 20 | Please consider buying shru some ice cream. Azuki preferred, but all 21 | flavours except Licorice will be accepted. 22 | 23 | In jurisdictions that do not: (a) recognize donation of works to the public 24 | domain; nor (b) consider incitement to be a legally enforcable crime: shru 25 | advocates immediate forceful regime-change. 26 | 27 | --]] 28 | 29 | local assertType 30 | do 31 | local ERROR_TEMPLATE = "%s must be a %s, got: %s" 32 | 33 | assertType = function(obj, expectedType, name) 34 | assert(type(expectedType) == 'string' and type(name) == 'string') 35 | if type(obj) ~= expectedType then 36 | error(ERROR_TEMPLATE:format(name, expectedType, tostring(obj)), 2) 37 | end 38 | end 39 | end 40 | 41 | local Ygg = {} 42 | 43 | local CLASS = { 44 | ACTION = 'ACTION', 45 | SELECTOR = 'SELECTOR', 46 | SEQUENCE = 'SEQUENCE', 47 | } 48 | local NO_NODE = {} 49 | 50 | do 51 | local Action = { 52 | class = CLASS.ACTION, -- Lawsuit incoming. 53 | } 54 | local ActionMT = {__index = Action} 55 | 56 | setmetatable(Ygg, { 57 | __call = function(_, ...) 58 | return setmetatable({}, ActionMT) 59 | :new(...) 60 | end, 61 | }) 62 | 63 | function Action:new(name, fn) 64 | if type(name) == 'function' then 65 | name, fn = '', name 66 | end 67 | assertType(fn, 'function', 'action function') 68 | self.name = name 69 | self.func = fn 70 | return self 71 | end 72 | end 73 | 74 | do 75 | local function _new(self, name) 76 | self.name = name 77 | self._actions = {} 78 | self._len = 0 79 | return self 80 | end 81 | 82 | local function _add(self, action) 83 | self._len = self._len + 1 84 | self._actions[self._len] = action 85 | return self 86 | end 87 | 88 | local function _update(self, runner, ...) 89 | while runner.index <= self._len do 90 | local node = self._actions[runner.index] 91 | if node.class ~= CLASS.ACTION then 92 | return nil, node -- Node is a metanode, push onto stack. 93 | end 94 | local result = node.func(...) 95 | if result == self._exitOnResult then 96 | return result, NO_NODE 97 | elseif result == nil then 98 | return nil, NO_NODE 99 | end 100 | runner.index = runner.index + 1 101 | end 102 | return self._statusIfFinished, NO_NODE 103 | end 104 | 105 | do 106 | local Selector = { 107 | class = CLASS.SELECTOR, 108 | new = _new, 109 | add = _add, 110 | update = _update, 111 | _exitOnResult = true, 112 | _statusIfFinished = false, 113 | } 114 | local SelectorMT = {__index = Selector} 115 | 116 | function Ygg.selector(...) 117 | return setmetatable({}, SelectorMT) 118 | :new(...) 119 | end 120 | end 121 | 122 | do 123 | local Sequence = { 124 | class = CLASS.SEQUENCE, 125 | new = _new, 126 | add = _add, 127 | update = _update, 128 | _exitOnResult = false, 129 | _statusIfFinished = true, 130 | } 131 | local SequenceMT = {__index = Sequence} 132 | 133 | function Ygg.sequence(...) 134 | return setmetatable({}, SequenceMT) 135 | :new(...) 136 | end 137 | end 138 | end 139 | 140 | do 141 | local Runner = {} 142 | local RunnerMT = {__index = Runner} 143 | 144 | function Ygg.run(...) 145 | return setmetatable({}, RunnerMT) 146 | :new(...) 147 | end 148 | 149 | function Runner:new(action) 150 | assertType(action, 'table', 'action') 151 | self._action = action 152 | self.index = 1 153 | self.status = nil 154 | return self 155 | end 156 | 157 | function Runner:update(...) 158 | if self._next then 159 | self._next:update(...) 160 | local status = self._next.status 161 | if status == nil then 162 | return 163 | end 164 | self._next = nil 165 | -- This shit kind of sucks, probably refactor eventually. 166 | if (self._action.class == CLASS.SEQUENCE and status == false) 167 | or (self._action.class == CLASS.SELECTOR and status == true) 168 | then 169 | self.status = status 170 | self.index = 1 171 | return 172 | else 173 | self.index = self.index + 1 174 | end 175 | end 176 | 177 | local status, node = self._action:update(self, ...) 178 | if status ~= nil then 179 | self.status = status 180 | self.index = 1 181 | elseif node ~= NO_NODE then 182 | self._next = Ygg.run(node) 183 | self:update(...) 184 | end 185 | end 186 | end 187 | 188 | return Ygg 189 | --------------------------------------------------------------------------------