feat: Add initial version of behavior_tree and flame_behavior_tree package (#3045)

First pass on adding behavior tree for flame. This PR adds 2 packages:

- behavior_tree: A pure dart implementation of behavior tree.
- flame_behavior_tree: A bridge package that integrates behavior_tree
with flame.

Demo: 


https://github.com/flame-engine/flame/assets/33748002/1d2b00ab-1b6e-406e-9052-a24370c8f1ab
This commit is contained in:
DevKage
2024-03-10 21:59:15 +05:30
committed by GitHub
parent f49d24c02d
commit faf2df4b8c
36 changed files with 1554 additions and 0 deletions

View File

@ -0,0 +1,10 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "bae5e49bc2a867403c43b2aae2de8f8c33b037e4"
channel: "stable"
project_type: package

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Blue Fire
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,82 @@
<!-- markdownlint-disable MD013 -->
<p align="center">
<a href="https://flame-engine.org">
<img alt="flame" width="200px" src="https://user-images.githubusercontent.com/6718144/101553774-3bc7b000-39ad-11eb-8a6a-de2daa31bd64.png">
</a>
</p>
<p align="center">This is a bridge package that integrates the <a href="https://github.com/flame-engine/flame/tree/main/packages/flame_behavior_tree/behavior_tree">behavior_tree</a> dart package with <a href="https://flame-engine.org/">Flame engine</a>.
</p>
<p align="center">
<a title="Pub" href="https://pub.dev/packages/flame_behavior_tree" ><img src="https://img.shields.io/pub/v/flame_behavior_tree.svg?style=popout" /></a>
<a title="Test" href="https://github.com/flame-engine/flame/actions?query=workflow%3Acicd+branch%3Amain"><img src="https://github.com/flame-engine/flame/workflows/cicd/badge.svg?branch=main&event=push"/></a>
<a title="Discord" href="https://discord.gg/pxrBmy4"><img src="https://img.shields.io/discord/509714518008528896.svg"/></a>
<a title="Melos" href="https://github.com/invertase/melos"><img src="https://img.shields.io/badge/maintained%20with-melos-f700ff.svg"/></a>
</p>
---
<!-- markdownlint-enable MD013 -->
## Features
This package provides a `HasBehaviorTree` mixin for Flame `Components`. It can be added to any
`Component` and it takes care of ticking the behavior tree along with the component's update.
## Getting started
Add this package to your Flutter project using:
```bash
flutter pub add flame_behavior_tree
```
## Usage
- Add the `HasBehaviorTree` mixin to the component that wants to follow a certain AI behavior.
```dart
class MyComponent extends Position with HasBehaviorTree {
}
```
- Set-up a behavior tree and set its root as the `treeRoot` of the `HasBehaviorTree`.
```dart
class MyComponent extends PositionComponent with HasBehaviorTree {
Future<void> onLoad() async {
treeRoot = Selector(
children: [
Sequence(children: [task1, condition, task2]),
Sequence(...),
]
);
super.onLoad();
}
}
```
- Increase the `tickInterval` to make the tree tick less frequently.
```dart
class MyComponent extends PositionComponent with HasBehaviorTree {
Future<void> onLoad() async {
treeRoot = Selector(...);
tickInterval = 4;
super.onLoad();
}
}
```
## Additional information
When working with behavior trees, keep in mind that
- nodes of a behavior tree do not necessarily update on every frame.
- avoid storing data in nodes as much as possible because it can go out of sync with rest of the
game as nodes are not ticked on every frame.

View File

@ -0,0 +1 @@
include: package:flame_lint/analysis_options.yaml

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Blue Fire
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,72 @@
<!-- markdownlint-disable MD013 -->
<p align="center">
<a href="https://flame-engine.org">
<img alt="flame" width="200px" src="https://user-images.githubusercontent.com/6718144/101553774-3bc7b000-39ad-11eb-8a6a-de2daa31bd64.png">
</a>
</p>
<p align="center">
This package provides a simple and easy to use <a href="https://en.wikipedia.org/wiki/Behavior_tree">behavior tree</a> API in pure dart.
</p>
<p align="center">
<a title="Pub" href="https://pub.dev/packages/behavior_tree" ><img src="https://img.shields.io/pub/v/behavior_tree.svg?style=popout" /></a>
<a title="Test" href="https://github.com/flame-engine/flame/actions?query=workflow%3Acicd+branch%3Amain"><img src="https://github.com/flame-engine/flame/workflows/cicd/badge.svg?branch=main&event=push"/></a>
<a title="Discord" href="https://discord.gg/pxrBmy4"><img src="https://img.shields.io/discord/509714518008528896.svg"/></a>
<a title="Melos" href="https://github.com/invertase/melos"><img src="https://img.shields.io/badge/maintained%20with-melos-f700ff.svg"/></a>
</p>
---
<!-- markdownlint-enable MD013 -->
Behavior tree is a very common way of implementing AI behavior in game and robotics. Using this, you
can break-down a complex behavior of an in game AI, into multiple smaller nodes.
## Features
- Nodes
- Composite
- Sequence: Continues execution until one of the children fails.
- Selector: Continues execution until one of the children succeeds.
- Decorator
- Inverter: Flips the status of the child node.
- Limiter: Limits the number of ticks for child node.
- Task
- Task: Executes a given callback when ticked.
- AsyncTask: Executes an async callback when ticked.
- Condition: Checks a condition when ticked.
## Getting started
Add this package to your dart project using,
```bash
dart pub add behavior_tree
```
## Usage
- Create a behavior tree.
```dart
final treeRoot = Sequence(
children: [
Condition(() => isHungry),
Task(() => goToShop()),
Task(() => buyFood()),
Task(() => goToHome()),
Task(() => eatFood()),
]
);
```
- Tick the root node to update the tree.
```dart
final treeRoot = ...;
treeRoot.tick();
```

View File

@ -0,0 +1 @@
include: package:flame_lint/analysis_options.yaml

View File

@ -0,0 +1,39 @@
import 'package:behavior_tree/behavior_tree.dart';
bool isHungry = true;
void main() {
// Create a sequence of tasks
final treeRoot = Sequence(
children: [
Condition(() => isHungry),
Task(goToShop),
Task(buyFood),
Task(goToHome),
Task(eatFood),
],
);
// Tick the tree
treeRoot.tick();
}
NodeStatus goToShop() {
// Go to the shop
return NodeStatus.success;
}
NodeStatus buyFood() {
// Buy food
return NodeStatus.success;
}
NodeStatus goToHome() {
// Go home
return NodeStatus.success;
}
NodeStatus eatFood() {
// Eat food
return NodeStatus.success;
}

View File

@ -0,0 +1,12 @@
/// Behavior tree implementation in dart
library behavior_tree;
export 'src/base_node.dart';
export 'src/composites/selector.dart';
export 'src/composites/sequence.dart';
export 'src/decorators/inverter.dart';
export 'src/decorators/limiter.dart';
export 'src/node.dart';
export 'src/tasks/async_task.dart';
export 'src/tasks/condition.dart';
export 'src/tasks/task.dart';

View File

@ -0,0 +1,21 @@
import 'package:behavior_tree/behavior_tree.dart';
import 'package:meta/meta.dart';
/// A base class for all the nodes.
abstract class BaseNode implements NodeInterface {
NodeStatus _status = NodeStatus.notStarted;
@override
NodeStatus get status => _status;
@override
set status(NodeStatus value) {
_status = value;
}
@override
@mustCallSuper
void reset() {
_status = NodeStatus.notStarted;
}
}

View File

@ -0,0 +1,31 @@
import 'package:behavior_tree/behavior_tree.dart';
/// A composite node that stops at its first non-failing child node.
class Selector extends BaseNode implements NodeInterface {
/// Creates a selector node for given [children] nodes.
Selector({List<NodeInterface>? children})
: _children = children ?? <NodeInterface>[];
final List<NodeInterface> _children;
@override
void tick() {
for (final node in _children) {
node.tick();
if (node.status != NodeStatus.failure) {
status = node.status;
return;
}
}
status = NodeStatus.failure;
}
@override
void reset() {
for (final node in _children) {
node.reset();
}
super.reset();
}
}

View File

@ -0,0 +1,31 @@
import 'package:behavior_tree/behavior_tree.dart';
/// A composite node that stops at its first successful child node.
class Sequence extends BaseNode implements NodeInterface {
/// Creates a sequence node for given [children] nodes.
Sequence({List<NodeInterface>? children})
: _children = children ?? <NodeInterface>[];
final List<NodeInterface> _children;
@override
void tick() {
for (final node in _children) {
node.tick();
if (node.status != NodeStatus.success) {
status = node.status;
return;
}
}
status = NodeStatus.success;
}
@override
void reset() {
for (final node in _children) {
node.reset();
}
super.reset();
}
}

View File

@ -0,0 +1,39 @@
import 'package:behavior_tree/behavior_tree.dart';
/// A decorator node that inverts [child]'s status if it is not
/// [NodeStatus.running].
class Inverter extends BaseNode implements NodeInterface {
/// Creates an inverter node for given [child] node.
Inverter(this.child) {
_invertStatus();
}
/// The child node whose status needs to be inverted.
final NodeInterface child;
@override
void tick() {
child.tick();
_invertStatus();
}
void _invertStatus() {
switch (child.status) {
case NodeStatus.notStarted:
status = NodeStatus.notStarted;
case NodeStatus.running:
status = NodeStatus.running;
case NodeStatus.success:
status = NodeStatus.failure;
case NodeStatus.failure:
status = NodeStatus.success;
}
}
@override
void reset() {
super.reset();
child.reset();
_invertStatus();
}
}

View File

@ -0,0 +1,48 @@
import 'package:behavior_tree/behavior_tree.dart';
/// A decorator node that limits the number of times [child] can be ticked.
class Limiter extends BaseNode implements NodeInterface {
/// Creates a limiter node for given [child] node and [limit].
///
/// Once this node has been ticked [limit] number of times, it stops ticking
/// the child node. After this, [status] will keep returning the status of
/// child the last time it was ticked. This behavior can be overridden by
/// providing an optional [statusAfterLimit].
Limiter(
this.child,
this.limit, {
NodeStatus? statusAfterLimit,
}) : _statusAfterLimit = statusAfterLimit {
status =
(_tickCount < limit) ? child.status : _statusAfterLimit ?? child.status;
}
var _tickCount = 0;
final NodeStatus? _statusAfterLimit;
/// The child node whose ticks are to be limited.
final NodeInterface child;
/// The max number of times [child] can be ticked.
final int limit;
/// Returns the number of times [child] has been ticked.
int get tickCount => _tickCount;
@override
void tick() {
if (_tickCount < limit) {
child.tick();
++_tickCount;
}
status =
(_tickCount < limit) ? child.status : _statusAfterLimit ?? child.status;
}
@override
void reset() {
_tickCount = 0;
child.reset();
super.reset();
}
}

View File

@ -0,0 +1,33 @@
import 'package:behavior_tree/behavior_tree.dart';
/// The valid values for status of a node.
enum NodeStatus {
/// Indicates that the node has not been ticked yet.
notStarted,
/// Indicates that the node is running.
running,
/// Indicates that the node has completed successfully.
success,
/// Indicates that the node has failed.
failure,
}
/// An interface which all the nodes implement.
///
/// Some examples are [Selector], [Sequence], [Inverter] and [Limiter].
abstract interface class NodeInterface {
/// Returns the current status of this node.
NodeStatus get status;
/// Sets the status of this node.
set status(NodeStatus value);
/// Updates the node and re-evaluates its status.
void tick();
/// Resets the node to its initial state.
void reset();
}

View File

@ -0,0 +1,27 @@
import 'dart:async';
import 'package:behavior_tree/behavior_tree.dart';
typedef AsyncTaskCallback = Future<NodeStatus> Function();
/// This is a leaf node that will execute the given async task when ticked.
/// While the callback is executing, this node will report [status] as
/// [NodeStatus.running]. Once the callback finishes, the status will be updated
/// to the returned value of the callback.
class AsyncTask extends BaseNode implements NodeInterface {
/// Creates an async task node for given [callback].
AsyncTask(AsyncTaskCallback callback) : _callback = callback;
final AsyncTaskCallback _callback;
@override
void tick() {
if (status != NodeStatus.running) {
status = NodeStatus.running;
_callback().then((returnedStatus) {
status = returnedStatus;
});
}
}
}

View File

@ -0,0 +1,18 @@
import 'package:behavior_tree/behavior_tree.dart';
typedef ConditionCallback = bool Function();
/// This is a leaf node that will updates its [status] based on
/// [conditionCallback].
class Condition extends BaseNode {
/// Creates a condition node for given [conditionCallback].
Condition(this.conditionCallback);
/// The callback that will be executed when the condition is ticked.
final ConditionCallback conditionCallback;
@override
void tick() {
status = conditionCallback() ? NodeStatus.success : NodeStatus.failure;
}
}

View File

@ -0,0 +1,17 @@
import 'package:behavior_tree/behavior_tree.dart';
/// The type of callback used by the [Task] node.
typedef TaskCallback = NodeStatus Function();
/// This is a leaf node that will execute the given task when ticked.
class Task extends BaseNode implements NodeInterface {
/// Creates a task node for given [taskCallback].
Task(this.taskCallback);
/// The callback that will be executed when the task is ticked.
/// It should return the status of the task.
final TaskCallback taskCallback;
@override
void tick() => status = taskCallback();
}

View File

@ -0,0 +1,20 @@
name: behavior_tree
description: A behavior tree implementation written in dart. This package is designed to be used for implementing AI in games.
version: 0.1.0
repository: https://github.com/flame-engine/flame/tree/main/packages/flame_behavior_tree/behavior_tree
funding:
- https://opencollective.com/blue-fire
- https://github.com/sponsors/bluefireteam
- https://patreon.com/bluefireoss
environment:
sdk: ">=3.0.0 <4.0.0"
dependencies:
meta: ^1.9.1
dev_dependencies:
flame_lint: ^1.1.2
lints: ^3.0.0
mocktail: ^1.0.1
test: any

View File

@ -0,0 +1,19 @@
import 'package:behavior_tree/behavior_tree.dart';
import 'package:test/test.dart';
void main() {
group('AsyncTask', () {
test('sets status to running when ticked.', () {
final asyncTask = AsyncTask(() async => NodeStatus.success);
asyncTask.tick();
expect(asyncTask.status, NodeStatus.running);
});
test('updates status to the returned value of the callback.', () async {
final asyncTask = AsyncTask(() async => NodeStatus.failure);
asyncTask.tick();
await Future.delayed(Duration.zero); // Wait for the callback to complete
expect(asyncTask.status, equals(NodeStatus.failure));
});
});
}

View File

@ -0,0 +1,18 @@
import 'package:behavior_tree/behavior_tree.dart';
import 'package:test/test.dart';
void main() {
group('Condition', () {
test('status is success when condition returns true', () {
final condition = Condition(() => true);
condition.tick();
expect(condition.status, NodeStatus.success);
});
test('status is failure when condition returns false', () {
final condition = Condition(() => false);
condition.tick();
expect(condition.status, NodeStatus.failure);
});
});
}

View File

@ -0,0 +1,45 @@
import 'package:behavior_tree/behavior_tree.dart';
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';
void main() {
group('Inverter', () {
final alwaysFailure = _MockNode();
final alwaysSuccess = _MockNode();
final alwaysRunning = _MockNode();
setUp(() {
reset(alwaysFailure);
reset(alwaysSuccess);
reset(alwaysRunning);
when(() => alwaysFailure.status).thenReturn(NodeStatus.failure);
when(() => alwaysSuccess.status).thenReturn(NodeStatus.success);
when(() => alwaysRunning.status).thenReturn(NodeStatus.running);
});
test('can be instantiated.', () {
expect(() => Inverter(alwaysRunning), returnsNormally);
});
test('default status is inverted child status.', () {
final inverter = Inverter(alwaysSuccess);
expect(inverter.status, NodeStatus.failure);
});
test('inverts status of child.', () {
final inverter1 = Inverter(alwaysSuccess)..tick();
expect(inverter1.status, NodeStatus.failure);
final inverter2 = Inverter(alwaysFailure)..tick();
expect(inverter2.status, NodeStatus.success);
});
test('keeping running if child is running.', () {
final inverter = Inverter(alwaysRunning)..tick();
expect(inverter.status, NodeStatus.running);
});
});
}
class _MockNode extends Mock implements NodeInterface {}

View File

@ -0,0 +1,71 @@
import 'package:behavior_tree/behavior_tree.dart';
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';
void main() {
group('Limiter', () {
final alwaysFailure = _MockNode();
final alwaysSuccess = _MockNode();
final alwaysRunning = _MockNode();
setUp(() {
reset(alwaysFailure);
reset(alwaysSuccess);
reset(alwaysRunning);
when(() => alwaysFailure.status).thenReturn(NodeStatus.failure);
when(() => alwaysSuccess.status).thenReturn(NodeStatus.success);
when(() => alwaysRunning.status).thenReturn(NodeStatus.running);
});
test('can be instantiated.', () {
expect(() => Limiter(alwaysRunning, 5), returnsNormally);
});
test('default status same as status of child.', () {
final limiter = Limiter(alwaysSuccess, 5);
expect(limiter.status, alwaysSuccess.status);
});
test('limits tick count of child.', () {
const limit = 5;
final limiter = Limiter(alwaysRunning, limit);
var count = 0;
while (count < 23) {
limiter.tick();
++count;
}
expect(limiter.tickCount, limit);
expect(limiter.status, alwaysRunning.status);
});
test('overrides status after crossing limit.', () {
const limit = 5;
final failAfterLimit = Limiter(
alwaysSuccess,
limit,
statusAfterLimit: NodeStatus.failure,
);
final succeedAfterLimit = Limiter(
alwaysRunning,
limit,
statusAfterLimit: NodeStatus.success,
);
var count = 0;
while (count < 23) {
failAfterLimit.tick();
succeedAfterLimit.tick();
++count;
}
expect(failAfterLimit.status, NodeStatus.failure);
expect(succeedAfterLimit.status, NodeStatus.success);
});
});
}
class _MockNode extends Mock implements NodeInterface {}

View File

@ -0,0 +1,128 @@
import 'package:behavior_tree/behavior_tree.dart';
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';
void main() {
group('Selector', () {
const nTries = 20;
final alwaysFailure = _MockNode();
final alwaysSuccess = _MockNode();
final alwaysRunning = _MockNode();
final successAfterTries = _StatusAfterNTries(nTries, NodeStatus.success);
final failureAfterTries = _StatusAfterNTries(nTries, NodeStatus.failure);
setUp(() {
reset(alwaysFailure);
reset(alwaysSuccess);
reset(alwaysRunning);
when(() => alwaysFailure.status).thenReturn(NodeStatus.failure);
when(() => alwaysSuccess.status).thenReturn(NodeStatus.success);
when(() => alwaysRunning.status).thenReturn(NodeStatus.running);
successAfterTries.reset();
failureAfterTries.reset();
});
test('can be instantiated without the children.', () {
expect(Selector.new, returnsNormally);
});
test('can be instantiated with the children.', () {
expect(
() => Selector(children: [Selector(), Selector()]),
returnsNormally,
);
});
test('default status is not started.', () {
final selector = Selector();
expect(selector.status, NodeStatus.notStarted);
});
test('can be ticked without the children.', () {
final selector = Selector();
expect(selector.tick, returnsNormally);
});
test('can be ticked with the children.', () {
final selector = Selector(children: [Selector(), Selector()]);
expect(selector.tick, returnsNormally);
});
test('succeeds if any one of the children succeeds.', () {
final selector = Selector(children: [alwaysFailure, alwaysSuccess])
..tick();
expect(selector.status, NodeStatus.success);
});
test('fails if all of the children fail.', () {
final selector = Selector(children: [alwaysFailure, alwaysFailure])
..tick();
expect(selector.status, NodeStatus.failure);
});
test('runs until all children fail.', () {
final selector = Selector(
children: [alwaysFailure, failureAfterTries],
);
var count = 0;
while (count <= nTries) {
selector.tick();
expect(
selector.status,
count == nTries ? NodeStatus.failure : NodeStatus.running,
);
++count;
}
verify(alwaysFailure.tick).called(count);
expect(failureAfterTries.tickCount, count);
});
test('runs until one of the children succeeds.', () {
final selector = Selector(children: [successAfterTries, alwaysFailure]);
var count = 0;
while (count <= nTries) {
selector.tick();
expect(
selector.status,
count == nTries ? NodeStatus.success : NodeStatus.running,
);
++count;
}
verifyNever(alwaysFailure.tick);
expect(successAfterTries.tickCount, count);
});
});
}
class _MockNode extends Mock implements NodeInterface {}
class _StatusAfterNTries extends BaseNode implements NodeInterface {
_StatusAfterNTries(this.nTries, this.statusAfterTries);
final int nTries;
final NodeStatus statusAfterTries;
var _tickCount = 0;
int get tickCount => _tickCount;
@override
void tick() {
status = _tickCount++ < nTries ? NodeStatus.running : statusAfterTries;
}
@override
void reset() {
super.reset();
_tickCount = 0;
}
}

View File

@ -0,0 +1,128 @@
import 'package:behavior_tree/behavior_tree.dart';
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';
void main() {
group('Sequence', () {
const nTries = 20;
final alwaysFailure = _MockNode();
final alwaysSuccess = _MockNode();
final alwaysRunning = _MockNode();
final successAfterTries = _StatusAfterNTries(nTries, NodeStatus.success);
final failureAfterTries = _StatusAfterNTries(nTries, NodeStatus.failure);
setUp(() {
reset(alwaysFailure);
reset(alwaysSuccess);
reset(alwaysRunning);
when(() => alwaysFailure.status).thenReturn(NodeStatus.failure);
when(() => alwaysSuccess.status).thenReturn(NodeStatus.success);
when(() => alwaysRunning.status).thenReturn(NodeStatus.running);
successAfterTries.reset();
failureAfterTries.reset();
});
test('can be instantiated without the children.', () {
expect(Sequence.new, returnsNormally);
});
test('can be instantiated with the children.', () {
expect(
() => Sequence(children: [Sequence(), Sequence()]),
returnsNormally,
);
});
test('default status is not started.', () {
final sequence = Sequence();
expect(sequence.status, NodeStatus.notStarted);
});
test('can be ticked without the children.', () {
final selector = Sequence();
expect(selector.tick, returnsNormally);
});
test('can be ticked with the children.', () {
final selector = Sequence(children: [Sequence(), Sequence()]);
expect(selector.tick, returnsNormally);
});
test('succeeds if all of the children succeed.', () {
final sequence = Sequence(children: [alwaysSuccess, alwaysSuccess])
..tick();
expect(sequence.status, NodeStatus.success);
});
test('fails if any of the children fails.', () {
final sequence = Sequence(children: [alwaysSuccess, alwaysFailure])
..tick();
expect(sequence.status, NodeStatus.failure);
});
test('runs until first failure.', () {
final sequence = Sequence(
children: [alwaysSuccess, failureAfterTries],
);
var count = 0;
while (count <= nTries) {
sequence.tick();
expect(
sequence.status,
count == nTries ? NodeStatus.failure : NodeStatus.running,
);
++count;
}
verify(alwaysSuccess.tick).called(count);
expect(failureAfterTries.tickCount, count);
});
test('runs until all children succeed.', () {
final sequence = Sequence(children: [alwaysSuccess, successAfterTries]);
var count = 0;
while (count <= nTries) {
sequence.tick();
expect(
sequence.status,
count == nTries ? NodeStatus.success : NodeStatus.running,
);
++count;
}
verify(alwaysSuccess.tick).called(count);
expect(successAfterTries.tickCount, count);
});
});
}
class _MockNode extends Mock implements NodeInterface {}
class _StatusAfterNTries extends BaseNode implements NodeInterface {
_StatusAfterNTries(this.nTries, this.statusAfterTries);
final int nTries;
final NodeStatus statusAfterTries;
var _tickCount = 0;
int get tickCount => _tickCount;
@override
void tick() {
status = _tickCount++ < nTries ? NodeStatus.running : statusAfterTries;
}
@override
void reset() {
super.reset();
_tickCount = 0;
}
}

View File

@ -0,0 +1,25 @@
import 'package:behavior_tree/behavior_tree.dart';
import 'package:test/test.dart';
void main() {
group('Task', () {
test('returns the status returned by the task callback', () {
const expectedStatus = NodeStatus.success;
final task = Task(() => expectedStatus);
task.tick();
expect(task.status, equals(expectedStatus));
});
test('executes the task callback when ticked', () {
var executed = false;
final task = Task(() {
executed = true;
return NodeStatus.success;
});
task.tick();
expect(executed, isTrue);
});
});
}

View File

@ -0,0 +1,42 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "bae5e49bc2a867403c43b2aae2de8f8c33b037e4"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
- platform: android
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
- platform: linux
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
- platform: macos
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
- platform: web
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
- platform: windows
create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

View File

@ -0,0 +1 @@
include: package:flame_lint/analysis_options.yaml

View File

@ -0,0 +1,323 @@
import 'dart:async';
import 'dart:math';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flame/palette.dart';
import 'package:flame_behavior_tree/flame_behavior_tree.dart';
import 'package:flutter/material.dart';
typedef MyGame = FlameGame<GameWorld>;
const gameWidth = 320.0;
const gameHeight = 180.0;
void main() {
runApp(const MainApp());
}
class MainApp extends StatelessWidget {
const MainApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: GameWidget<MyGame>.controlled(
gameFactory: () => MyGame(
world: GameWorld(),
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
),
),
),
);
}
}
class GameWorld extends World with HasGameReference {
@override
Future<void> onLoad() async {
game.camera.moveTo(Vector2(gameWidth * 0.5, gameHeight * 0.5));
final house = RectangleComponent(
size: Vector2(100, 100),
position: Vector2(gameWidth * 0.5, 10),
paint: BasicPalette.cyan.paint()
..strokeWidth = 5
..style = PaintingStyle.stroke,
anchor: Anchor.topCenter,
);
final door = Door(
size: Vector2(20, 4),
position: Vector2(40, house.size.y),
anchor: Anchor.centerLeft,
);
final agent = Agent(
door: door,
house: house,
position: Vector2(gameWidth * 0.76, gameHeight * 0.9),
);
await house.add(door);
await addAll([house, agent]);
}
}
class Door extends RectangleComponent with TapCallbacks {
Door({super.position, super.size, super.anchor})
: super(paint: BasicPalette.brown.paint());
bool isOpen = false;
bool _isInProgress = false;
bool _isKnocking = false;
@override
void onTapDown(TapDownEvent event) {
if (!_isInProgress) {
_isInProgress = true;
add(
RotateEffect.to(
isOpen ? 0 : -pi * 0.5,
EffectController(duration: 0.5, curve: Curves.easeInOut),
onComplete: () {
isOpen = !isOpen;
_isInProgress = false;
},
),
);
}
}
void knock() {
if (!_isKnocking) {
_isKnocking = true;
add(
MoveEffect.by(
Vector2(0, -1),
EffectController(
alternate: true,
duration: 0.1,
repeatCount: 2,
),
onComplete: () {
_isKnocking = false;
},
),
);
}
}
}
class Agent extends PositionComponent with HasBehaviorTree {
Agent({required this.door, required this.house, required Vector2 position})
: _startPosition = position.clone(),
super(position: position);
final Door door;
final PositionComponent house;
final Vector2 _startPosition;
@override
Future<void> onLoad() async {
await add(CircleComponent(radius: 3, anchor: Anchor.center));
_setupBehaviorTree();
super.onLoad();
}
void _setupBehaviorTree() {
var isInside = false;
var isAtTheDoor = false;
var isAtCenterOfHouse = false;
var isMoving = false;
var wantsToGoOutside = false;
final walkTowardsDoorInside = Task(() {
if (!isAtTheDoor) {
isMoving = true;
add(
MoveEffect.to(
door.absolutePosition + Vector2(door.size.x * 0.8, -15),
EffectController(
duration: 3,
curve: Curves.easeInOut,
),
onComplete: () {
isMoving = false;
isAtTheDoor = true;
isAtCenterOfHouse = false;
},
),
);
}
return isAtTheDoor ? NodeStatus.success : NodeStatus.running;
});
final stepOutTheDoor = Task(() {
if (isInside) {
isMoving = true;
add(
MoveEffect.to(
door.absolutePosition + Vector2(door.size.x * 0.5, 10),
EffectController(
duration: 2,
curve: Curves.easeInOut,
),
onComplete: () {
isMoving = false;
isInside = false;
},
),
);
}
return !isInside ? NodeStatus.success : NodeStatus.running;
});
final walkTowardsInitialPosition = Task(
() {
if (isAtTheDoor) {
isMoving = true;
isAtTheDoor = false;
add(
MoveEffect.to(
_startPosition,
EffectController(
duration: 3,
curve: Curves.easeInOut,
),
onComplete: () {
isMoving = false;
wantsToGoOutside = false;
},
),
);
}
return !wantsToGoOutside ? NodeStatus.success : NodeStatus.running;
},
);
final walkTowardsDoorOutside = Task(() {
if (!isAtTheDoor) {
isMoving = true;
add(
MoveEffect.to(
door.absolutePosition + Vector2(door.size.x * 0.5, 10),
EffectController(
duration: 3,
curve: Curves.easeInOut,
),
onComplete: () {
isMoving = false;
isAtTheDoor = true;
},
),
);
}
return isAtTheDoor ? NodeStatus.success : NodeStatus.running;
});
final walkTowardsCenterOfTheHouse = Task(() {
if (!isAtCenterOfHouse) {
isMoving = true;
isInside = true;
add(
MoveEffect.to(
house.absoluteCenter,
EffectController(
duration: 3,
curve: Curves.easeInOut,
),
onComplete: () {
isMoving = false;
wantsToGoOutside = true;
isAtTheDoor = false;
isAtCenterOfHouse = true;
},
),
);
}
return isInside ? NodeStatus.success : NodeStatus.running;
});
final checkIfDoorIsOpen = Condition(() => door.isOpen);
final knockTheDoor = Task(() {
door.knock();
return NodeStatus.success;
});
final goOutsideSequence = Sequence(
children: [
Condition(() => wantsToGoOutside),
Selector(
children: [
Sequence(
children: [
Condition(() => isInside),
walkTowardsDoorInside,
Selector(
children: [
Sequence(
children: [
checkIfDoorIsOpen,
stepOutTheDoor,
],
),
knockTheDoor,
],
),
],
),
walkTowardsInitialPosition,
],
),
],
);
final goInsideSequence = Sequence(
children: [
Condition(() => !wantsToGoOutside),
Selector(
children: [
Sequence(
children: [
Condition(() => !isInside),
walkTowardsDoorOutside,
Selector(
children: [
Sequence(
children: [
checkIfDoorIsOpen,
walkTowardsCenterOfTheHouse,
],
),
knockTheDoor,
],
),
],
),
],
),
],
);
treeRoot = Selector(
children: [
Condition(() => isMoving),
goOutsideSequence,
goInsideSequence,
],
);
tickInterval = 2;
}
}

View File

@ -0,0 +1,25 @@
name: flame_behavior_tree_example
description: "A simple demo to show usage of behavior trees in flame."
publish_to: 'none'
version: 0.1.0
funding:
- https://opencollective.com/blue-fire
- https://github.com/sponsors/bluefireteam
- https://patreon.com/bluefireoss
environment:
sdk: ">=3.0.0 <4.0.0"
dependencies:
flame: ^1.16.0
flame_behavior_tree: ^0.1.0
flutter:
sdk: flutter
dev_dependencies:
flame_lint: ^1.1.2
flutter_test:
sdk: flutter
flutter:
uses-material-design: true

View File

@ -0,0 +1,5 @@
/// A bridge package that integrates behavior_tree package with flame.
library flame_behavior_tree;
export 'package:behavior_tree/behavior_tree.dart';
export 'src/has_behavior_tree.dart';

View File

@ -0,0 +1,57 @@
import 'dart:async';
import 'package:behavior_tree/behavior_tree.dart';
import 'package:flame/components.dart';
import 'package:flutter/foundation.dart';
/// A mixin on [Component] to indicate that the component has a behavior tree.
///
/// Reference to the behavior tree for this component can be set or accessed
/// via [treeRoot]. The update frequency of the tree can be reduced by
/// increasing [tickInterval]. By default, the tree will be updated on every
/// update of the component.
mixin HasBehaviorTree<T extends NodeInterface> on Component {
T? _treeRoot;
Timer? _timer;
double _tickInterval = 0;
/// The delay between any two ticks of the behavior tree.
double get tickInterval => _tickInterval;
set tickInterval(double interval) {
_tickInterval = interval;
if (_tickInterval > 0) {
_timer ??= Timer(interval, repeat: true);
_timer?.limit = interval;
} else {
_timer?.onTick = null;
_timer = null;
_tickInterval = 0;
}
}
/// The root node of the behavior tree.
T get treeRoot => _treeRoot!;
set treeRoot(T value) {
_treeRoot = value;
_timer?.onTick = _treeRoot!.tick;
}
@override
@mustCallSuper
Future<void> onLoad() async {
super.onLoad();
_timer?.onTick = _treeRoot?.tick;
}
@override
@mustCallSuper
void update(double dt) {
super.update(dt);
if (_tickInterval > 0) {
_timer?.update(dt);
} else {
_treeRoot?.tick();
}
}
}

View File

@ -0,0 +1,25 @@
name: flame_behavior_tree
description: A bridge package that integrates behavior_tree package with flame.
version: 0.1.0
homepage: https://github.com/flame-engine/flame/tree/main/packages/flame_behavior_tree
funding:
- https://opencollective.com/blue-fire
- https://github.com/sponsors/bluefireteam
- https://patreon.com/bluefireoss
environment:
sdk: ">=3.0.0 <4.0.0"
flutter: ">=3.19.0"
dependencies:
behavior_tree: ^1.0.0
flame: ^1.16.0
flutter:
sdk: flutter
dev_dependencies:
flame_lint: ^1.1.2
flame_test: ^1.16.0
flutter_test:
sdk: flutter
mocktail: ^1.0.1

View File

@ -0,0 +1,98 @@
import 'package:flame/components.dart';
import 'package:flame_behavior_tree/flame_behavior_tree.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
void main() {
group('HasBehaviorTree', () {
final alwaysFailure = _MockNode();
final alwaysSuccess = _MockNode();
final alwaysRunning = _MockNode();
setUp(() {
reset(alwaysFailure);
reset(alwaysSuccess);
reset(alwaysRunning);
when(() => alwaysFailure.status).thenReturn(NodeStatus.failure);
when(() => alwaysSuccess.status).thenReturn(NodeStatus.success);
when(() => alwaysRunning.status).thenReturn(NodeStatus.running);
});
testWithFlameGame(
'updates with null tree.',
(game) async {
final component = _BehaviorTreeComponent();
expect(() => game.add(component), returnsNormally);
},
);
test('tick interval can be changed', () {
final component = _BehaviorTreeComponent();
expect(component.tickInterval, 0);
component.tickInterval = 3;
expect(component.tickInterval, 3);
component.tickInterval = -53;
expect(component.tickInterval, 0);
});
test('throws if treeNode is accessed before setting.', () {
final component = _BehaviorTreeComponent();
expect(() => component.treeRoot, throwsA(isA<TypeError>()));
component.treeRoot = _MockNode();
expect(() => component.treeRoot, returnsNormally);
});
testWithFlameGame(
'updates without errors with a valid tree.',
(game) async {
final component = _BehaviorTreeComponent()
..treeRoot =
Sequence(children: [alwaysSuccess, alwaysFailure, alwaysRunning]);
expect(() async => await game.add(component), returnsNormally);
await game.ready();
expect(() => game.update(10), returnsNormally);
verify(alwaysSuccess.tick).called(1);
verify(alwaysFailure.tick).called(1);
verifyNever(alwaysRunning.tick);
},
);
testWithFlameGame(
'tree updates at a slower rate.',
(game) async {
final component = _BehaviorTreeComponent()
..treeRoot =
Sequence(children: [alwaysSuccess, alwaysFailure, alwaysRunning])
..tickInterval = 1;
await game.add(component);
await game.ready();
const dt = 1 / 60;
const gameTime = 3.0;
var elapsedTime = 0.0;
while (elapsedTime < gameTime) {
game.update(dt);
elapsedTime += dt;
}
verify(alwaysSuccess.tick).called(gameTime.toInt());
verify(alwaysFailure.tick).called(gameTime.toInt());
verifyNever(alwaysRunning.tick);
},
);
});
}
class _BehaviorTreeComponent extends Component with HasBehaviorTree {}
class _MockNode extends Mock implements NodeInterface {}