diff --git a/packages/flame_behavior_tree/.metadata b/packages/flame_behavior_tree/.metadata new file mode 100644 index 000000000..48326e7c2 --- /dev/null +++ b/packages/flame_behavior_tree/.metadata @@ -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 diff --git a/packages/flame_behavior_tree/CHANGELOG.md b/packages/flame_behavior_tree/CHANGELOG.md new file mode 100644 index 000000000..e69de29bb diff --git a/packages/flame_behavior_tree/LICENSE b/packages/flame_behavior_tree/LICENSE new file mode 100644 index 000000000..0cf87ff46 --- /dev/null +++ b/packages/flame_behavior_tree/LICENSE @@ -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. diff --git a/packages/flame_behavior_tree/README.md b/packages/flame_behavior_tree/README.md new file mode 100644 index 000000000..8740e79e0 --- /dev/null +++ b/packages/flame_behavior_tree/README.md @@ -0,0 +1,82 @@ + +

+ + flame + +

+ +

This is a bridge package that integrates the behavior_tree dart package with Flame engine. +

+ +

+ + + + +

+ +--- + + + +## 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 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 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. diff --git a/packages/flame_behavior_tree/analysis_options.yaml b/packages/flame_behavior_tree/analysis_options.yaml new file mode 100644 index 000000000..ba5631f3b --- /dev/null +++ b/packages/flame_behavior_tree/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flame_lint/analysis_options.yaml \ No newline at end of file diff --git a/packages/flame_behavior_tree/behavior_tree/CHANGELOG.md b/packages/flame_behavior_tree/behavior_tree/CHANGELOG.md new file mode 100644 index 000000000..e69de29bb diff --git a/packages/flame_behavior_tree/behavior_tree/LICENSE b/packages/flame_behavior_tree/behavior_tree/LICENSE new file mode 100644 index 000000000..0cf87ff46 --- /dev/null +++ b/packages/flame_behavior_tree/behavior_tree/LICENSE @@ -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. diff --git a/packages/flame_behavior_tree/behavior_tree/README.md b/packages/flame_behavior_tree/behavior_tree/README.md new file mode 100644 index 000000000..b29e372ac --- /dev/null +++ b/packages/flame_behavior_tree/behavior_tree/README.md @@ -0,0 +1,72 @@ + +

+ + flame + +

+ +

+This package provides a simple and easy to use behavior tree API in pure dart. +

+ +

+ + + + +

+ +--- + + + +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(); +``` diff --git a/packages/flame_behavior_tree/behavior_tree/analysis_options.yaml b/packages/flame_behavior_tree/behavior_tree/analysis_options.yaml new file mode 100644 index 000000000..85732fa02 --- /dev/null +++ b/packages/flame_behavior_tree/behavior_tree/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flame_lint/analysis_options.yaml diff --git a/packages/flame_behavior_tree/behavior_tree/example/behavior_tree_example.dart b/packages/flame_behavior_tree/behavior_tree/example/behavior_tree_example.dart new file mode 100644 index 000000000..b45d76675 --- /dev/null +++ b/packages/flame_behavior_tree/behavior_tree/example/behavior_tree_example.dart @@ -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; +} diff --git a/packages/flame_behavior_tree/behavior_tree/lib/behavior_tree.dart b/packages/flame_behavior_tree/behavior_tree/lib/behavior_tree.dart new file mode 100644 index 000000000..c6e6ece57 --- /dev/null +++ b/packages/flame_behavior_tree/behavior_tree/lib/behavior_tree.dart @@ -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'; diff --git a/packages/flame_behavior_tree/behavior_tree/lib/src/base_node.dart b/packages/flame_behavior_tree/behavior_tree/lib/src/base_node.dart new file mode 100644 index 000000000..8c67e57ec --- /dev/null +++ b/packages/flame_behavior_tree/behavior_tree/lib/src/base_node.dart @@ -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; + } +} diff --git a/packages/flame_behavior_tree/behavior_tree/lib/src/composites/selector.dart b/packages/flame_behavior_tree/behavior_tree/lib/src/composites/selector.dart new file mode 100644 index 000000000..a4a12cad8 --- /dev/null +++ b/packages/flame_behavior_tree/behavior_tree/lib/src/composites/selector.dart @@ -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? children}) + : _children = children ?? []; + + final List _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(); + } +} diff --git a/packages/flame_behavior_tree/behavior_tree/lib/src/composites/sequence.dart b/packages/flame_behavior_tree/behavior_tree/lib/src/composites/sequence.dart new file mode 100644 index 000000000..8e32b01b7 --- /dev/null +++ b/packages/flame_behavior_tree/behavior_tree/lib/src/composites/sequence.dart @@ -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? children}) + : _children = children ?? []; + + final List _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(); + } +} diff --git a/packages/flame_behavior_tree/behavior_tree/lib/src/decorators/inverter.dart b/packages/flame_behavior_tree/behavior_tree/lib/src/decorators/inverter.dart new file mode 100644 index 000000000..776dbf887 --- /dev/null +++ b/packages/flame_behavior_tree/behavior_tree/lib/src/decorators/inverter.dart @@ -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(); + } +} diff --git a/packages/flame_behavior_tree/behavior_tree/lib/src/decorators/limiter.dart b/packages/flame_behavior_tree/behavior_tree/lib/src/decorators/limiter.dart new file mode 100644 index 000000000..57086f604 --- /dev/null +++ b/packages/flame_behavior_tree/behavior_tree/lib/src/decorators/limiter.dart @@ -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(); + } +} diff --git a/packages/flame_behavior_tree/behavior_tree/lib/src/node.dart b/packages/flame_behavior_tree/behavior_tree/lib/src/node.dart new file mode 100644 index 000000000..9bed85a11 --- /dev/null +++ b/packages/flame_behavior_tree/behavior_tree/lib/src/node.dart @@ -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(); +} diff --git a/packages/flame_behavior_tree/behavior_tree/lib/src/tasks/async_task.dart b/packages/flame_behavior_tree/behavior_tree/lib/src/tasks/async_task.dart new file mode 100644 index 000000000..aa06bf494 --- /dev/null +++ b/packages/flame_behavior_tree/behavior_tree/lib/src/tasks/async_task.dart @@ -0,0 +1,27 @@ +import 'dart:async'; + +import 'package:behavior_tree/behavior_tree.dart'; + +typedef AsyncTaskCallback = Future 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; + }); + } + } +} diff --git a/packages/flame_behavior_tree/behavior_tree/lib/src/tasks/condition.dart b/packages/flame_behavior_tree/behavior_tree/lib/src/tasks/condition.dart new file mode 100644 index 000000000..1e1c37135 --- /dev/null +++ b/packages/flame_behavior_tree/behavior_tree/lib/src/tasks/condition.dart @@ -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; + } +} diff --git a/packages/flame_behavior_tree/behavior_tree/lib/src/tasks/task.dart b/packages/flame_behavior_tree/behavior_tree/lib/src/tasks/task.dart new file mode 100644 index 000000000..7ffc147ed --- /dev/null +++ b/packages/flame_behavior_tree/behavior_tree/lib/src/tasks/task.dart @@ -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(); +} diff --git a/packages/flame_behavior_tree/behavior_tree/pubspec.yaml b/packages/flame_behavior_tree/behavior_tree/pubspec.yaml new file mode 100644 index 000000000..840acab01 --- /dev/null +++ b/packages/flame_behavior_tree/behavior_tree/pubspec.yaml @@ -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 diff --git a/packages/flame_behavior_tree/behavior_tree/test/async_task_test.dart b/packages/flame_behavior_tree/behavior_tree/test/async_task_test.dart new file mode 100644 index 000000000..65e71f9f7 --- /dev/null +++ b/packages/flame_behavior_tree/behavior_tree/test/async_task_test.dart @@ -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)); + }); + }); +} diff --git a/packages/flame_behavior_tree/behavior_tree/test/conditon_test.dart b/packages/flame_behavior_tree/behavior_tree/test/conditon_test.dart new file mode 100644 index 000000000..335f0e686 --- /dev/null +++ b/packages/flame_behavior_tree/behavior_tree/test/conditon_test.dart @@ -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); + }); + }); +} diff --git a/packages/flame_behavior_tree/behavior_tree/test/inverter_test.dart b/packages/flame_behavior_tree/behavior_tree/test/inverter_test.dart new file mode 100644 index 000000000..e6a93c33d --- /dev/null +++ b/packages/flame_behavior_tree/behavior_tree/test/inverter_test.dart @@ -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 {} diff --git a/packages/flame_behavior_tree/behavior_tree/test/limiter_test.dart b/packages/flame_behavior_tree/behavior_tree/test/limiter_test.dart new file mode 100644 index 000000000..963442666 --- /dev/null +++ b/packages/flame_behavior_tree/behavior_tree/test/limiter_test.dart @@ -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 {} diff --git a/packages/flame_behavior_tree/behavior_tree/test/selector_test.dart b/packages/flame_behavior_tree/behavior_tree/test/selector_test.dart new file mode 100644 index 000000000..baef7ef3e --- /dev/null +++ b/packages/flame_behavior_tree/behavior_tree/test/selector_test.dart @@ -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; + } +} diff --git a/packages/flame_behavior_tree/behavior_tree/test/sequence_test.dart b/packages/flame_behavior_tree/behavior_tree/test/sequence_test.dart new file mode 100644 index 000000000..9d492d4e4 --- /dev/null +++ b/packages/flame_behavior_tree/behavior_tree/test/sequence_test.dart @@ -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; + } +} diff --git a/packages/flame_behavior_tree/behavior_tree/test/task_test.dart b/packages/flame_behavior_tree/behavior_tree/test/task_test.dart new file mode 100644 index 000000000..7b94edfd8 --- /dev/null +++ b/packages/flame_behavior_tree/behavior_tree/test/task_test.dart @@ -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); + }); + }); +} diff --git a/packages/flame_behavior_tree/example/.metadata b/packages/flame_behavior_tree/example/.metadata new file mode 100644 index 000000000..251f0182b --- /dev/null +++ b/packages/flame_behavior_tree/example/.metadata @@ -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' diff --git a/packages/flame_behavior_tree/example/analysis_options.yaml b/packages/flame_behavior_tree/example/analysis_options.yaml new file mode 100644 index 000000000..85732fa02 --- /dev/null +++ b/packages/flame_behavior_tree/example/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flame_lint/analysis_options.yaml diff --git a/packages/flame_behavior_tree/example/lib/main.dart b/packages/flame_behavior_tree/example/lib/main.dart new file mode 100644 index 000000000..dbf3f83c2 --- /dev/null +++ b/packages/flame_behavior_tree/example/lib/main.dart @@ -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; +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.controlled( + gameFactory: () => MyGame( + world: GameWorld(), + camera: CameraComponent.withFixedResolution( + width: gameWidth, + height: gameHeight, + ), + ), + ), + ), + ); + } +} + +class GameWorld extends World with HasGameReference { + @override + Future 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 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; + } +} diff --git a/packages/flame_behavior_tree/example/pubspec.yaml b/packages/flame_behavior_tree/example/pubspec.yaml new file mode 100644 index 000000000..4155b245c --- /dev/null +++ b/packages/flame_behavior_tree/example/pubspec.yaml @@ -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 diff --git a/packages/flame_behavior_tree/lib/flame_behavior_tree.dart b/packages/flame_behavior_tree/lib/flame_behavior_tree.dart new file mode 100644 index 000000000..e90ce54b3 --- /dev/null +++ b/packages/flame_behavior_tree/lib/flame_behavior_tree.dart @@ -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'; diff --git a/packages/flame_behavior_tree/lib/src/has_behavior_tree.dart b/packages/flame_behavior_tree/lib/src/has_behavior_tree.dart new file mode 100644 index 000000000..e978745d2 --- /dev/null +++ b/packages/flame_behavior_tree/lib/src/has_behavior_tree.dart @@ -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 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 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(); + } + } +} diff --git a/packages/flame_behavior_tree/pubspec.yaml b/packages/flame_behavior_tree/pubspec.yaml new file mode 100644 index 000000000..9975ab1d0 --- /dev/null +++ b/packages/flame_behavior_tree/pubspec.yaml @@ -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 diff --git a/packages/flame_behavior_tree/test/has_behavior_tree_test.dart b/packages/flame_behavior_tree/test/has_behavior_tree_test.dart new file mode 100644 index 000000000..c7f0d36ec --- /dev/null +++ b/packages/flame_behavior_tree/test/has_behavior_tree_test.dart @@ -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())); + + 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 {}