└── addons └── utility_ai ├── LICENSE ├── core ├── utility_ai.gd ├── utility_ai_behavior.gd ├── utility_ai_consideration.gd ├── utility_ai_option.gd └── utility_ai_response_curve.gd ├── editor └── response_curve_inspector.gd ├── plugin.cfg └── plugin.gd /addons/utility_ai/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 John Pennycook 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /addons/utility_ai/core/utility_ai.gd: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 John Pennycook 2 | # SPDX-License-Identifier: MIT 3 | class_name UtilityAI 4 | ## A plugin for building utility-based AI in Godot. 5 | ## 6 | ## A number of common functions for working with instances of other UtilityAI 7 | ## classes are provided as static functions in the UtilityAI class. 8 | 9 | 10 | ## Choose randomly between the highest-scoring options. An option is considered 11 | ## one of the highest-scoring options if it is within the specified tolerance of 12 | ## the option(s) with the maximum score. 13 | static func choose_highest( 14 | options: Array[UtilityAIOption], tolerance: float = 0.0 15 | ) -> UtilityAIOption: 16 | # Calculate the scores for every option. 17 | var scores := {} 18 | for option in options: 19 | scores[option] = option.evaluate() 20 | 21 | # Identify the highest-scoring options by sorting them. 22 | options.sort_custom(func(a, b): return scores[a] < scores[b]) 23 | 24 | # Choose randomly between all options within the specified tolerance. 25 | var high_score: float = scores[options[len(options) - 1]] 26 | var within_tolerance := func(o): return ( 27 | absf(high_score - scores[o]) <= tolerance 28 | ) 29 | return options.filter(within_tolerance).pick_random() 30 | 31 | 32 | ## Choose randomly between all options. The probability of choosing a specific 33 | ## option is based on its calculated utility. 34 | ## [br][br] 35 | ## [b]Note[/b]: This function does not require the utility values associated 36 | ## with the options to sum to 1. 37 | static func choose_random(options: Array[UtilityAIOption]) -> UtilityAIOption: 38 | # Choose a random number between [0, sum) 39 | var sum := options.reduce(func(accum, x): return accum + x.score, 0.0) 40 | var rand := randf_range(0, sum) 41 | 42 | # Find the first value bigger than rand 43 | var running_total := 0.0 44 | for option in options: 45 | running_total += option.score 46 | if running_total >= rand: 47 | return option 48 | return options.back() 49 | 50 | 51 | ## Sample the value of the binary curve described by the optional arguments. 52 | static func sample_binary(x: float, x_shift := 0.0, y_shift := 0.0) -> float: 53 | var threshold := clampf(0.5 + x_shift, 0.0, 1.0) 54 | var lo := clampf(0.0 + y_shift, 0.0, 1.0) 55 | var hi := clampf(1.0 + y_shift, 0.0, 1.0) 56 | if x < threshold and not is_equal_approx(x, threshold): 57 | return lo 58 | return hi 59 | 60 | 61 | ## Sample the value of the linear curve described by the optional arguments. 62 | static func sample_linear( 63 | x: float, x_shift := 0.0, y_shift := 0.0, slope := 1 64 | ) -> float: 65 | return clampf(slope * (x - x_shift) + y_shift, 0.0, 1.0) 66 | 67 | 68 | ## Sample the value of the exponential curve described by the optional arguments. 69 | static func sample_exponential( 70 | x: float, x_shift := 0.0, y_shift := 0.0, slope := 1, exponent := 2 71 | ) -> float: 72 | return clampf(slope * pow(x - x_shift, exponent) + y_shift, 0.0, 1.0) 73 | 74 | 75 | ## Sample the value of the logistic curve described by the optional arguments. 76 | static func sample_logistic( 77 | x: float, x_shift := 0.0, y_shift := 0.0, slope := 1, exponent := 1 78 | ) -> float: 79 | return clampf( 80 | slope / (1.0 + exp(-exponent * (x - 0.5 - x_shift))) + y_shift, 0.0, 1.0 81 | ) 82 | -------------------------------------------------------------------------------- /addons/utility_ai/core/utility_ai_behavior.gd: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 John Pennycook 2 | # SPDX-License-Identifier: MIT 3 | class_name UtilityAIBehavior 4 | extends Resource 5 | ## Determines the utility of an action. 6 | ## 7 | ## A behavior has a [member name] (for debugging purposes), a list of 8 | ## [member considerations] that must be evaluated to calculate utility, and a 9 | ## value denoting the [member aggregation] process used to combine the utility 10 | ## scores calculated for each consideration. 11 | 12 | ## How utility scores should be combined. 13 | enum AggregationType { 14 | PRODUCT, ## Utility scores should be multiplied together. 15 | AVERAGE, ## Utility scores should be averaged. 16 | MAXIMUM, ## All utility scores except the maximum should be discarded. 17 | MINIMUM, ## All utility scores except the minimum should be discarded. 18 | } 19 | 20 | ## A descriptive name for this behavior. 21 | ## [br][br] 22 | ## [b]Note[/b]: This value is not used by the UtilityAI plugin in any way, but 23 | ## may be useful for debugging. 24 | @export var name: StringName 25 | 26 | ## How the final utility score for this behavior should be computed from the 27 | ## utility scores for each consideration. 28 | @export var aggregation: AggregationType = AggregationType.PRODUCT 29 | 30 | ## A list of [UtilityAIConsideration] objects that should be used to evaluate 31 | ## the utility of this behavior. 32 | @export var considerations: Array[UtilityAIConsideration] = [] 33 | 34 | 35 | func _init( 36 | p_name: StringName = "", 37 | p_aggregation: AggregationType = AggregationType.PRODUCT, 38 | p_considerations: Array[UtilityAIConsideration] = [] 39 | ): 40 | name = p_name 41 | aggregation = p_aggregation 42 | considerations = p_considerations 43 | 44 | 45 | func _aggregate(scores: Array[float]) -> float: 46 | match aggregation: 47 | AggregationType.PRODUCT: 48 | return scores.reduce(func(accum, x): return accum * x) 49 | 50 | AggregationType.AVERAGE: 51 | return scores.reduce(func(accum, x): return accum + x) / len(scores) 52 | 53 | AggregationType.MAXIMUM: 54 | return scores.max() 55 | 56 | AggregationType.MINIMUM: 57 | return scores.min() 58 | 59 | push_error("Unrecognized AggregationType: %d" % [aggregation]) 60 | return 0 61 | 62 | 63 | ## Calculate the utility of this behavior in the specified context. 64 | func evaluate(context: Variant) -> float: 65 | var scores: Array[float] = [] 66 | for consideration in considerations: 67 | var score := consideration.evaluate(context) 68 | scores.append(score) 69 | return _aggregate(scores) 70 | 71 | 72 | func _to_string() -> String: 73 | return ( 74 | "[: %s, %s, %s]" 75 | % [self.get_instance_id(), name, aggregation, considerations] 76 | ) 77 | -------------------------------------------------------------------------------- /addons/utility_ai/core/utility_ai_consideration.gd: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 John Pennycook 2 | # SPDX-License-Identifier: MIT 3 | class_name UtilityAIConsideration 4 | extends Resource 5 | ## Describes an input variable and its impact upon utility calculations. 6 | ## 7 | ## A consideration is a mapping between an [member input_key] and the 8 | ## [member response_curve] that should be used to calculate a utility score. 9 | 10 | ## The name of the input variable, which must be the name of a property in the 11 | ## decision context that will be passed to [method evaluate]. 12 | @export var input_key: String 13 | 14 | ## Whether to invert the value (i.e., by subtracting it from 1) before 15 | ## using it to calculate utility. 16 | @export var invert: bool 17 | 18 | ## Describes the mapping from input values to utility scores. 19 | @export var response_curve: UtilityAIResponseCurve 20 | 21 | 22 | func _init( 23 | p_input_key: String = "", p_invert: bool = false, p_response_curve = null 24 | ): 25 | input_key = p_input_key 26 | invert = p_invert 27 | response_curve = p_response_curve 28 | 29 | 30 | ## Calculate the utility of this consideration in the specified decision context. 31 | func evaluate(context: Variant) -> float: 32 | var x: float = context.get(input_key) 33 | if invert: 34 | x = 1 - x 35 | return response_curve.evaluate(x) 36 | 37 | 38 | func _to_string() -> String: 39 | return ( 40 | "[: %s, %s, %s]" 41 | % [self.get_instance_id(), input_key, invert, response_curve] 42 | ) 43 | -------------------------------------------------------------------------------- /addons/utility_ai/core/utility_ai_option.gd: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 John Pennycook 2 | # SPDX-License-Identifier: MIT 3 | @tool 4 | class_name UtilityAIOption 5 | extends Resource 6 | ## Describes a single option to evaluate. 7 | ## 8 | ## An option pairs a [member behavior] with the specific decision 9 | ## [member context] in which it should be evaluated. An option can also store 10 | ## an optional [member action] that should be triggered in the event that this 11 | ## option is chosen. 12 | 13 | ## The behavior that will drive the evaluation of this option's utility. 14 | @export var behavior: UtilityAIBehavior 15 | 16 | ## The specific decision context that should be used to evaluate this option. 17 | ## [br][br] 18 | ## [b]Note[/b]: Anything can be used as a decision context, as long as it 19 | ## provides a [code]get()[/code] method that allows considerations to look-up 20 | ## the values of input values. Common examples include: a [Resource] 21 | ## describing an agent's state, or a [Dictionary] mapping input keys to input 22 | ## values. 23 | var context: Variant 24 | 25 | ## An optional value describing any action(s) that should be triggered in the 26 | ## event that this option is chosen. 27 | ## [br][br] 28 | ## [b]Note[/b]: This variable is not used by the plugin, but can be used to 29 | ## associate a [UtilityAIOption] with an action. Anything can be used as an 30 | ## action, but common examples include: a [Callable] that directly affects 31 | ## gameplay, a [Dictionary] of values to pass to some other function, or a 32 | ## [Resource] describing the chosen action. 33 | var action: Variant 34 | 35 | 36 | func _init( 37 | p_behavior: UtilityAIBehavior = null, 38 | p_context: Variant = null, 39 | p_action: Variant = null 40 | ): 41 | behavior = p_behavior 42 | context = p_context 43 | action = p_action 44 | 45 | 46 | ## Calculate the utility of this option, using [member behavior] and 47 | ## [member context]. Equivalent to calling: 48 | ## [code]behavior.evaluate(context)[/code]. 49 | func evaluate() -> float: 50 | return behavior.evaluate(context) 51 | 52 | 53 | func _to_string() -> String: 54 | return ( 55 | "[: %s, %s, %s]" 56 | % [self.get_instance_id(), action, behavior, context] 57 | ) 58 | 59 | 60 | # The fake "action_type" property exposed by _set, _get and _get_property_list() 61 | # is a workaround for the lack of Inspector support for editing Variant 62 | func _set(property: StringName, value: Variant) -> bool: 63 | if property == "action_type": 64 | match value: 65 | TYPE_NIL: 66 | action = null 67 | TYPE_STRING: 68 | action = String() 69 | TYPE_OBJECT: 70 | action = Object.new() 71 | TYPE_DICTIONARY: 72 | action = Dictionary() 73 | TYPE_ARRAY: 74 | action = Array() 75 | notify_property_list_changed() 76 | return true 77 | return false 78 | 79 | 80 | func _get(property: StringName) -> Variant: 81 | if property == "action_type": 82 | return typeof(action) 83 | return null 84 | 85 | 86 | func _get_property_list(): 87 | var properties = [] 88 | properties.append( 89 | { 90 | "name": "action_type", 91 | "type": TYPE_INT, 92 | "usage": PROPERTY_USAGE_EDITOR, 93 | "hint": PROPERTY_HINT_ENUM, 94 | "hint_string": "Variant:0,String:4,Object:24,Dictionary:27,Array:28" 95 | } 96 | ) 97 | properties.append( 98 | { 99 | "name": "action", 100 | "type": get("action_type"), 101 | "usage": PROPERTY_USAGE_DEFAULT | PROPERTY_USAGE_NIL_IS_VARIANT, 102 | "hint": PROPERTY_HINT_NONE, 103 | } 104 | ) 105 | return properties 106 | -------------------------------------------------------------------------------- /addons/utility_ai/core/utility_ai_response_curve.gd: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 John Pennycook 2 | # SPDX-License-Identifier: MIT 3 | @tool 4 | class_name UtilityAIResponseCurve 5 | extends Curve 6 | ## A curve used to calculate utility from a value. 7 | ## 8 | ## A [UtilityAIResponseCurve] describes a relationship between a normalized 9 | ## input value (i.e., an input value in the range [i][0, 1][/i]) and the desired 10 | ## utility value. 11 | ## [br][br] 12 | ## The response curve is stored as a sequence of points managed by [Curve], and 13 | ## can therefore represent any arbitrary piecewise function. For convenience, a 14 | ## number of built-in curves that are typically used in utility systems 15 | ## (binary, linear, exponential and logistic curve) are also available. 16 | ## [br][br] 17 | ## The utility value associated with a given input value can be calculated by 18 | ## calling the [method evaluate] method. 19 | ## [br][br] 20 | ## [b]Warning[/b]: Although the [method sample] method inherited from [Curve] 21 | ## may also be used to calculate utility, this is not recommended. In a future 22 | ## version of the plugin, the behavior of these functions may not be identical. 23 | 24 | ## Whether this curve is one of several built-in types, or a custom curve. 25 | enum CurveType { 26 | BINARY, ## A curve that is 0 until a threshold, then 1 afterwards. 27 | LINEAR, ## A straight line between two points. 28 | EXPONENTIAL, ## A simple curve with x raised to the specified power. 29 | LOGISTIC, ## An S-shaped curve, similar to a smoothed binary curve. 30 | CUSTOM, ## A custom [Curve] described by a sequence of points. 31 | } 32 | 33 | ## The type of the curve, as represented by an [enum CurveType]. 34 | @export var curve_type: CurveType = CurveType.CUSTOM: 35 | set = _set_curve_type 36 | 37 | ## The exponent of the curve (if applicable). 38 | @export_range(1, 100) var exponent: int = 1: 39 | set(value): 40 | exponent = value 41 | _update_points() 42 | 43 | ## The slope of the curve (if applicable). 44 | @export_range(0, 100) var slope: int = 1: 45 | set(value): 46 | slope = value 47 | _update_points() 48 | 49 | ## The amount to shift the curve along the x-axis. 50 | @export_range(-1.0, +1.0) var x_shift: float = 0.0: 51 | set(value): 52 | x_shift = value 53 | _update_points() 54 | 55 | ## The amount to shift the curve along the y-axis. 56 | @export_range(-1.0, +1.0) var y_shift: float = 0.0: 57 | set(value): 58 | y_shift = value 59 | _update_points() 60 | 61 | var _ignore_changed := false 62 | 63 | 64 | func _is_utility_ai_response_curve(): 65 | pass 66 | 67 | 68 | func _init(p_curve_type: CurveType = CurveType.CUSTOM, arg: Variant = null): 69 | changed.connect(_on_changed) 70 | 71 | curve_type = p_curve_type 72 | match p_curve_type: 73 | CurveType.CUSTOM: 74 | if arg != null: 75 | if not arg is Curve: 76 | push_error("For CUSTOM curve, arg must be a Curve") 77 | else: 78 | _data = arg._data 79 | _: 80 | if arg != null: 81 | if not arg is Dictionary: 82 | push_error("For non-CUSTOM curve, arg must be a Dictionary") 83 | else: 84 | if "exponent" in arg: 85 | exponent = arg.get("exponent") 86 | if "slope" in arg: 87 | slope = arg.get("slope") 88 | if "x_shift" in arg: 89 | x_shift = arg.get("x_shift") 90 | if "y_shift" in arg: 91 | y_shift = arg.get("y_shift") 92 | 93 | 94 | func _set_curve_type(p_curve_type: CurveType): 95 | curve_type = p_curve_type 96 | notify_property_list_changed() 97 | 98 | exponent = 1 99 | slope = 1 100 | x_shift = 0.0 101 | y_shift = 0.0 102 | 103 | match curve_type: 104 | CurveType.EXPONENTIAL: 105 | exponent = 2 106 | CurveType.LOGISTIC: 107 | exponent = 10 108 | 109 | _update_points() 110 | 111 | 112 | func _clamp(x: float) -> float: 113 | return clampf(x, 0.0, 1.0) 114 | 115 | 116 | func _add_points(f: Callable, npoints: int = 10) -> void: 117 | for i in range(0, npoints + 1): 118 | var x := float(i) / npoints 119 | var y := f.call(x) 120 | add_point( 121 | Vector2(x, y), 0, 0, Curve.TANGENT_LINEAR, Curve.TANGENT_LINEAR 122 | ) 123 | 124 | 125 | func _update_points(npoints: int = 10) -> void: 126 | if curve_type == CurveType.CUSTOM: 127 | return 128 | 129 | _ignore_changed = true 130 | 131 | clear_points() 132 | 133 | if curve_type == CurveType.BINARY: 134 | var threshold := _clamp(0.5 + x_shift) 135 | var lo := _clamp(0.0 + y_shift) 136 | var hi := _clamp(1.0 + y_shift) 137 | if threshold == 0: 138 | add_point(Vector2(0, hi)) 139 | add_point(Vector2(1, hi)) 140 | else: 141 | # This ordering of add_point() is required for correct behavior. 142 | # Godot otherwise inverts the order of points 2 and 3. 143 | add_point(Vector2(0, lo)) 144 | add_point(Vector2(threshold, hi)) 145 | add_point(Vector2(threshold, lo)) 146 | add_point(Vector2(1, hi)) 147 | elif curve_type == CurveType.LINEAR: 148 | var linear = func(x): return _clamp(slope * (x - x_shift) + y_shift) 149 | _add_points(linear, npoints) 150 | elif curve_type == CurveType.EXPONENTIAL: 151 | var exponential = func(x): return _clamp( 152 | slope * pow(x - x_shift, exponent) + y_shift 153 | ) 154 | _add_points(exponential, npoints) 155 | elif curve_type == CurveType.LOGISTIC: 156 | var logistic = func(x): return _clamp( 157 | slope / (1.0 + exp(-exponent * (x - 0.5 - x_shift))) + y_shift 158 | ) 159 | _add_points(logistic, npoints) 160 | 161 | _ignore_changed = false 162 | 163 | 164 | func _on_changed() -> void: 165 | if _ignore_changed: 166 | return 167 | if curve_type != CurveType.CUSTOM: 168 | _set_curve_type(CurveType.CUSTOM) 169 | 170 | 171 | ## Returns the utility value for the input value [code]x[/code]. 172 | func evaluate(x: float) -> float: 173 | return super.sample(x) 174 | 175 | 176 | func _to_string() -> String: 177 | const NAMES := { 178 | CurveType.BINARY: "BINARY", 179 | CurveType.LINEAR: "LINEAR", 180 | CurveType.EXPONENTIAL: "EXPONENTIAL", 181 | CurveType.LOGISTIC: "LOGISTIC", 182 | CurveType.CUSTOM: "CUSTOM", 183 | } 184 | 185 | if curve_type == CurveType.CUSTOM: 186 | return ( 187 | "[: %s]" 188 | % [self.get_instance_id(), "CUSTOM"] 189 | ) 190 | 191 | var name: String = NAMES[curve_type] 192 | var fmt := "[: %s, (%d, %d, %f, %f)]" 193 | return ( 194 | fmt % [self.get_instance_id(), name, exponent, slope, x_shift, y_shift] 195 | ) 196 | -------------------------------------------------------------------------------- /addons/utility_ai/editor/response_curve_inspector.gd: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 John Pennycook 2 | # SPDX-License-Identifier: MIT 3 | @tool 4 | extends EditorInspectorPlugin 5 | 6 | 7 | func _can_handle(object): 8 | return object.has_method("_is_utility_ai_response_curve") 9 | 10 | 11 | func _parse_property( 12 | object, _type, name, _hint_type, _hint_string, _usage_flags, _wide 13 | ): 14 | if object == null: 15 | return 16 | 17 | var properties_to_hide := ["min_value", "max_value"] 18 | 19 | if object.curve_type == UtilityAIResponseCurve.CurveType.BINARY: 20 | properties_to_hide += ["slope", "exponent"] 21 | elif object.curve_type == UtilityAIResponseCurve.CurveType.LINEAR: 22 | properties_to_hide += ["exponent"] 23 | elif object.curve_type == UtilityAIResponseCurve.CurveType.EXPONENTIAL: 24 | properties_to_hide += [] 25 | elif object.curve_type == UtilityAIResponseCurve.CurveType.LOGISTIC: 26 | properties_to_hide += [] 27 | elif object.curve_type == UtilityAIResponseCurve.CurveType.CUSTOM: 28 | properties_to_hide += ["exponent", "slope", "x_shift", "y_shift"] 29 | 30 | return name in properties_to_hide 31 | -------------------------------------------------------------------------------- /addons/utility_ai/plugin.cfg: -------------------------------------------------------------------------------- 1 | [plugin] 2 | 3 | name="Utility AI" 4 | description="Utility AI for Godot." 5 | author="John Pennycook" 6 | version="0.2.0" 7 | script="plugin.gd" 8 | -------------------------------------------------------------------------------- /addons/utility_ai/plugin.gd: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 John Pennycook 2 | # SPDX-License-Identifier: MIT 3 | @tool 4 | extends EditorPlugin 5 | 6 | 7 | var response_curve_inspector = preload("res://addons/utility_ai/editor/response_curve_inspector.gd").new() 8 | 9 | 10 | func _enter_tree(): 11 | add_inspector_plugin(response_curve_inspector) 12 | 13 | 14 | func _exit_tree(): 15 | remove_inspector_plugin(response_curve_inspector) 16 | --------------------------------------------------------------------------------