Skip to content

Commit

Permalink
weighted random selector/sequence (#206)
Browse files Browse the repository at this point in the history
* weighted random selector/sequence

* added tests for weighted random composites

* bug fixes

- renaming will not reset the weight
- selector composite was not reversing the children bag (weights were
having the opposite effect)

* added tests for both sequence and selector random composites with weights

---------

Co-authored-by: miguel <miguel-gonzalez@gmx.de>
  • Loading branch information
lostptr and bitbrain authored Aug 21, 2023
1 parent 977b989 commit f07d169
Show file tree
Hide file tree
Showing 14 changed files with 601 additions and 33 deletions.
152 changes: 152 additions & 0 deletions addons/beehave/nodes/composites/randomized_composite.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
@tool
class_name RandomizedComposite extends Composite

const WEIGHTS_PREFIX = "Weights/"

## Sets a predicable seed
@export var random_seed: int = 0:
set(rs):
random_seed = rs
if random_seed != 0:
seed(random_seed)
else:
randomize()

## Wether to use weights for every child or not.
@export var use_weights: bool:
set(value):
use_weights = value
if use_weights:
_update_weights(get_children())
_connect_children_changing_signals()
notify_property_list_changed()

var _weights: Dictionary


func _ready():
_connect_children_changing_signals()


func _connect_children_changing_signals():
if not child_entered_tree.is_connected(_on_child_entered_tree):
child_entered_tree.connect(_on_child_entered_tree)

if not child_exiting_tree.is_connected(_on_child_exiting_tree):
child_exiting_tree.connect(_on_child_exiting_tree)


func get_shuffled_children() -> Array[Node]:
var children_bag: Array[Node] = get_children().duplicate()
if use_weights:
var weights: Array[int]
weights.assign(children_bag.map(func (child): return _weights[child.name]))
children_bag.assign(_weighted_shuffle(children_bag, weights))
else:
children_bag.shuffle()
return children_bag


## Returns a shuffled version of a given array using the supplied array of weights.
## Think of weights as the chance of a given item being the first in the array.
func _weighted_shuffle(items: Array, weights: Array[int]) -> Array:
if len(items) != len(weights):
push_error("items and weights size mismatch: expected %d weights, got %d instead." % [len(items), len(weights)])
return items

# This method is based on the weighted random sampling algorithm
# by Efraimidis, Spirakis; 2005. This runs in O(n log(n)).

# For each index, it will calculate random_value^(1/weight).
var chance_calc = func(i): return [i, randf() ** (1.0 / weights[i])]
var random_distribuition = range(len(items)).map(chance_calc)

# Now we just have to order by the calculated value, descending.
random_distribuition.sort_custom(func(a, b): return a[1] > b[1])

return random_distribuition.map(func(dist): return items[dist[0]])


func _get_property_list():
var properties = []

if use_weights:
for key in _weights.keys():
properties.append({
"name": WEIGHTS_PREFIX + key,
"type": TYPE_INT,
"usage": PROPERTY_USAGE_STORAGE | PROPERTY_USAGE_EDITOR,
"hint": PROPERTY_HINT_RANGE,
"hint_string": "1,100"
})

return properties


func _set(property: StringName, value: Variant) -> bool:
if property.begins_with(WEIGHTS_PREFIX):
var weight_name = property.trim_prefix(WEIGHTS_PREFIX)
_weights[weight_name] = value
return true

return false


func _get(property: StringName):
if property.begins_with(WEIGHTS_PREFIX):
var weight_name = property.trim_prefix(WEIGHTS_PREFIX)
return _weights[weight_name]

return null


func _update_weights(children: Array[Node]) -> void:
var new_weights = {}
for c in children:
if _weights.has(c.name):
new_weights[c.name] = _weights[c.name]
else:
new_weights[c.name] = 1
_weights = new_weights
notify_property_list_changed()


func _on_child_entered_tree(node: Node):
_update_weights(get_children())

var renamed_callable = _on_child_renamed.bind(node.name, node)
if not node.renamed.is_connected(renamed_callable):
node.renamed.connect(renamed_callable)


func _on_child_exiting_tree(node: Node):
var renamed_callable = _on_child_renamed.bind(node.name, node)
if node.renamed.is_connected(renamed_callable):
node.renamed.disconnect(renamed_callable)

var children = get_children()
children.erase(node)
_update_weights(children)


func _on_child_renamed(old_name: String, renamed_child: Node):
if old_name == renamed_child.name:
return # No need to update the weights.

# Disconnect signal with old name...
renamed_child.renamed\
.disconnect(_on_child_renamed.bind(old_name, renamed_child))
# ...and connect with the new name.
renamed_child.renamed\
.connect(_on_child_renamed.bind(renamed_child.name, renamed_child))

var original_weight = _weights[old_name]
_weights.erase(old_name)
_weights[renamed_child.name] = original_weight
notify_property_list_changed()


func get_class_name() -> Array[StringName]:
var classes := super()
classes.push_back(&"RandomizedComposite")
return classes
20 changes: 6 additions & 14 deletions addons/beehave/nodes/composites/selector_random.gd
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,18 @@
## will be executed in a random order.
@tool
@icon("../../icons/selector_random.svg")
class_name SelectorRandomComposite extends Composite

## Sets a predicable seed
@export var random_seed:int = 0:
set(rs):
random_seed = rs
if random_seed != 0:
seed(random_seed)
else:
randomize()

class_name SelectorRandomComposite extends RandomizedComposite

## A shuffled list of the children that will be executed in reverse order.
var _children_bag: Array[Node] = []
var c: Node

func _ready() -> void:
super()
if random_seed == 0:
randomize()


func tick(actor: Node, blackboard: Blackboard) -> int:
if _children_bag.is_empty():
_reset()
Expand Down Expand Up @@ -76,10 +68,10 @@ func _get_reversed_indexes() -> Array[int]:
return reversed


## Generates a new shuffled list of the children.
func _reset() -> void:
_children_bag = get_children().duplicate()
_children_bag.shuffle()
var new_order = get_shuffled_children()
_children_bag = new_order.duplicate()
_children_bag.reverse() # It needs to run the children in reverse order.


func get_class_name() -> Array[StringName]:
Expand Down
20 changes: 8 additions & 12 deletions addons/beehave/nodes/composites/sequence_random.gd
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,23 @@
## will be executed in a random order.
@tool
@icon("../../icons/sequence_random.svg")
class_name SequenceRandomComposite extends Composite
class_name SequenceRandomComposite extends RandomizedComposite

# Emitted whenever the children are shuffled.
signal reset(new_order: Array[Node])

## Whether the sequence should start where it left off after a previous failure.
@export var resume_on_failure: bool = false
## Whether the sequence should start where it left off after a previous interruption.
@export var resume_on_interrupt: bool = false
## Sets a predicable seed
@export var random_seed: int = 0:
set(rs):
random_seed = rs
if random_seed != 0:
seed(random_seed)
else:
randomize()

## A shuffled list of the children that will be executed in reverse order.
var _children_bag: Array[Node] = []
var c: Node


func _ready() -> void:
super()
if random_seed == 0:
randomize()

Expand Down Expand Up @@ -84,10 +79,11 @@ func _get_reversed_indexes() -> Array[int]:
return reversed


## Generates a new shuffled list of the children.
func _reset() -> void:
_children_bag = get_children().duplicate()
_children_bag.shuffle()
var new_order = get_shuffled_children()
_children_bag = new_order.duplicate()
_children_bag.reverse() # It needs to run the children in reverse order.
reset.emit(new_order)


func get_class_name() -> Array[StringName]:
Expand Down
10 changes: 5 additions & 5 deletions test/nodes/composites/selector_random_test.gd
Original file line number Diff line number Diff line change
Expand Up @@ -45,19 +45,19 @@ func test_always_executing_first_successful_node() -> void:

func test_execute_second_when_first_is_failing() -> void:
selector.random_seed = RANDOM_SEED
action1.status = BeehaveNode.FAILURE
action2.status = BeehaveNode.FAILURE
assert_that(selector.tick(actor, blackboard)).is_equal(BeehaveNode.SUCCESS)
assert_that(selector.tick(actor, blackboard)).is_equal(BeehaveNode.SUCCESS)
assert_that(action1.count).is_equal(1)
assert_that(action2.count).is_equal(2)
assert_that(action2.count).is_equal(1)
assert_that(action1.count).is_equal(2)


func test_random_even_execution() -> void:
selector.random_seed = RANDOM_SEED
assert_that(selector.tick(actor, blackboard)).is_equal(BeehaveNode.SUCCESS)
assert_that(action1.count).is_equal(1)
assert_that(selector.tick(actor, blackboard)).is_equal(BeehaveNode.SUCCESS)
assert_that(action2.count).is_equal(1)
assert_that(selector.tick(actor, blackboard)).is_equal(BeehaveNode.SUCCESS)
assert_that(action1.count).is_equal(1)


func test_return_failure_of_none_is_succeeding() -> void:
Expand Down
37 changes: 35 additions & 2 deletions test/nodes/composites/sequence_random_test.gd
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ var action2: ActionLeaf
func before_test() -> void:
tree = auto_free(load(__tree).new())
action1 = auto_free(load(__count_up_action).new())
action1.name = 'Action 1'
action2 = auto_free(load(__count_up_action).new())
action2.name = 'Action 2'
sequence = auto_free(load(__source).new())
sequence.random_seed = RANDOM_SEED
var actor = auto_free(Node2D.new())
Expand Down Expand Up @@ -63,14 +65,45 @@ func test_random_even_execution() -> void:
assert_that(action2.count).is_equal(2)


func test_weighted_random_sampling() -> void:
sequence.use_weights = true
sequence._weights[action1.name] = 2
assert_dict(sequence._weights).contains_key_value(action1.name, 2)
assert_dict(sequence._weights).contains_key_value(action2.name, 1)

action1.status = BeehaveNode.RUNNING
action2.status = BeehaveNode.RUNNING

assert_array(sequence._children_bag).is_empty()

assert_that(tree.tick()).is_equal(BeehaveNode.RUNNING)

# Children are in reverse order; aka action1 will run first.
assert_array(sequence._children_bag)\
.contains_exactly([action2, action1])

# Only action 1 should have executed.
assert_that(action1.count).is_equal(1)
assert_that(action2.count).is_equal(0)

action1.status = BeehaveNode.SUCCESS

assert_that(tree.tick()).is_equal(BeehaveNode.RUNNING)

assert_that(action1.count).is_equal(2)
assert_that(action2.count).is_equal(1)

sequence.use_weights = false


func test_return_failure_of_none_is_succeeding() -> void:
action1.status = BeehaveNode.FAILURE
action2.status = BeehaveNode.FAILURE

assert_that(tree.tick()).is_equal(BeehaveNode.FAILURE)

assert_that(action1.count).is_equal(1)
assert_that(action2.count).is_equal(0)
assert_that(action1.count).is_equal(0)
assert_that(action2.count).is_equal(1)


func test_clear_running_child_after_run() -> void:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
extends Node2D

@onready var sequence_random: SequenceRandomComposite = %SequenceRandom
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
[gd_scene load_steps=5 format=3 uid="uid://dhhw4ej2jbyha"]

[ext_resource type="Script" path="res://addons/beehave/nodes/beehave_tree.gd" id="1_10c1m"]
[ext_resource type="Script" path="res://test/randomized_composites/runtime_changes/RuntimeChangesTestScene.gd" id="1_folsk"]
[ext_resource type="Script" path="res://addons/beehave/nodes/composites/sequence_random.gd" id="2_k8ytk"]
[ext_resource type="Script" path="res://test/actions/mock_action.gd" id="3_kqvkq"]

[node name="RuntimeChangesTestScene" type="Node2D"]
script = ExtResource("1_folsk")

[node name="BeehaveTree" type="Node" parent="."]
script = ExtResource("1_10c1m")

[node name="SequenceRandom" type="Node" parent="BeehaveTree"]
unique_name_in_owner = true
script = ExtResource("2_k8ytk")
random_seed = 12345
use_weights = true
Weights/Idle = 1
Weights/Run = 1
"Weights/Attack Meele" = 1
"Weights/Attack Ranged" = 1

[node name="Idle" type="Node" parent="BeehaveTree/SequenceRandom"]
script = ExtResource("3_kqvkq")

[node name="Run" type="Node" parent="BeehaveTree/SequenceRandom"]
script = ExtResource("3_kqvkq")

[node name="Attack Meele" type="Node" parent="BeehaveTree/SequenceRandom"]
script = ExtResource("3_kqvkq")

[node name="Attack Ranged" type="Node" parent="BeehaveTree/SequenceRandom"]
script = ExtResource("3_kqvkq")
Loading

0 comments on commit f07d169

Please sign in to comment.