From 1ddfa176599cb3ab1c190e56e52983872ecddcbb Mon Sep 17 00:00:00 2001 From: Sara Date: Tue, 4 Jun 2024 21:42:09 +0200 Subject: [PATCH] feat: implemented goal oriented action planning --- src/goap/action.cpp | 36 +++--- src/goap/action.hpp | 43 +++---- src/goap/action_db.cpp | 40 ++++--- src/goap/action_db.hpp | 20 ++-- src/goap/actor_world_state.cpp | 8 ++ src/goap/actor_world_state.hpp | 3 + src/goap/goal.cpp | 27 +++++ src/goap/goal.hpp | 8 +- src/goap/planner.cpp | 209 ++++++++++++++++++++++++++++++--- src/goap/planner.hpp | 58 ++++++++- src/register_types.cpp | 8 +- 11 files changed, 360 insertions(+), 100 deletions(-) create mode 100644 src/goap/goal.cpp diff --git a/src/goap/action.cpp b/src/goap/action.cpp index 7011195..b9be10a 100644 --- a/src/goap/action.cpp +++ b/src/goap/action.cpp @@ -1,11 +1,24 @@ #include "action.hpp" namespace goap { -Action::Action(WorldState &requires, WorldState &effects) -: requires{requires}, effects{effects} {} +Action::~Action() {} + +bool Action::is_possible(ActorWorldState *context) const { + return this->procedural_is_possible(context); +} + +bool Action::is_completed(ActorWorldState *context) const { + if(!only_proc_is_completed) { + for(WorldProperty const &prop : this->effects) { + if(!context->check_property_match(prop)) + return false; + } + } + return this->procedural_is_completed(context); +} WorldState const &Action::get_required() const { - return this->requires; + return this->required; } WorldState const &Action::get_effects() const { @@ -16,22 +29,11 @@ ActionID Action::get_id() const { return this->id; } -ActionData::ActionData(Action *action) -: data{action} {} - -ActionData::~ActionData() { - if(this->data != nullptr) - delete this->data; -} -} - -bool TestAction::is_done_for(goap::ActorWorldState *) { +bool Action::procedural_is_possible(ActorWorldState *context) const { return true; } -bool TestAction::is_possible_for(goap::ActorWorldState *) { + +bool Action::procedural_is_completed(ActorWorldState *context) const { return true; } -goap::State *TestAction::get_apply_state() const { - return nullptr; } - diff --git a/src/goap/action.hpp b/src/goap/action.hpp index 0c6913a..b3336f8 100644 --- a/src/goap/action.hpp +++ b/src/goap/action.hpp @@ -10,7 +10,7 @@ #define GOAP_ACTION(Name_) \ public: \ _FORCE_INLINE_ static gd::String get_static_class() { return #Name_; }\ - _FORCE_INLINE_ gd::String get_class() const { return #Name_; }\ + _FORCE_INLINE_ virtual gd::String get_class() const override { return #Name_; }\ private: namespace goap { @@ -22,44 +22,33 @@ typedef int ActionID; */ class Action { friend class ActionDB; - GOAP_ACTION(Action); public: - virtual ~Action() = default; + static gd::String get_static_class() { return "Action"; } + virtual gd::String get_class() const { return "Action"; } - virtual bool is_possible_for(ActorWorldState *state) = 0; - virtual bool is_done_for(ActorWorldState *state) = 0; + virtual ~Action(); virtual State *get_apply_state() const = 0; + bool is_completed(ActorWorldState *context) const; + bool is_possible(ActorWorldState *context) const; + WorldState const &get_required() const; WorldState const &get_effects() const; ActionID get_id() const; protected: Action() = default; - Action(WorldState &requires, WorldState &effects); -private: - WorldState requires{}; - WorldState effects{}; - ActionID id{-1}; -}; -/* Storage wrapper for managed action class. - */ -struct ActionData { - ActionData() = default; - ActionData(Action *action); - ~ActionData(); - Action *data{nullptr}; + virtual bool procedural_is_possible(ActorWorldState *context) const; + virtual bool procedural_is_completed(ActorWorldState *context) const; +protected: + WorldState blocking_required{}; + WorldState required{}; + WorldState effects{}; + bool only_proc_is_completed{false}; +private: + ActionID id{-1}; }; } -class TestAction : public goap::Action { - GOAP_ACTION(TestAction); -public: - TestAction() = default; - virtual bool is_done_for(goap::ActorWorldState *) override; - virtual bool is_possible_for(goap::ActorWorldState *) override; - virtual goap::State *get_apply_state() const override; -}; - #endif // !GOAP_ACTION_HPP diff --git a/src/goap/action_db.cpp b/src/goap/action_db.cpp index 70b27fd..a0f03fd 100644 --- a/src/goap/action_db.cpp +++ b/src/goap/action_db.cpp @@ -1,36 +1,42 @@ #include "action_db.hpp" +#include "godot_cpp/variant/utility_functions.hpp" namespace goap { -StaticString::~StaticString() { - if(this->ptr != nullptr) - delete this->ptr; +ActionDB::StaticData::~StaticData() { + if(this->hint != nullptr) + delete this->hint; + for(Action *action : this->actions) + delete action; } -gd::String &StaticString::get() { - if(this->ptr == nullptr) - this->ptr = new gd::String(); - return *this->ptr; +gd::String &ActionDB::StaticData::get_hint() { + if(this->hint == nullptr) + this->hint = new gd::String(); + return *this->hint; } ActionID ActionDB::register_action(Action *instance, gd::String name) { - instance->id = ActionDB::actions.size(); - if(!ActionDB::hint.get().is_empty()) - ActionDB::hint.get() += ","; - ActionDB::hint.get() += name; - ActionDB::actions.push_back(ActionData(instance)); + instance->id = ActionDB::data.actions.size(); + if(!ActionDB::data.get_hint().is_empty()) + ActionDB::data.get_hint() += ","; + ActionDB::data.get_hint() += name; + // defer intialization until after emplacement to avoid deleting instance + ActionDB::data.actions.push_back(instance); + gd::UtilityFunctions::print("registered action ", name); return instance->get_id(); } Action const *ActionDB::get_action(ActionID index) { - if(ActionDB::actions.size() >= index || index < 0) + if(ActionDB::data.actions.size() <= index || index < 0) { + gd::UtilityFunctions::push_warning("Attempted to get pointer to non-existent Action by ActionID ", index); return nullptr; - return ActionDB::actions[index].data; + } + return ActionDB::data.actions[index]; } gd::String const &ActionDB::get_enum_hint() { - return ActionDB::hint.get(); + return ActionDB::data.get_hint(); } -gd::Vector ActionDB::actions{gd::Vector()}; -StaticString ActionDB::hint{}; +ActionDB::StaticData ActionDB::data{}; } diff --git a/src/goap/action_db.hpp b/src/goap/action_db.hpp index 1db4012..1f1e3da 100644 --- a/src/goap/action_db.hpp +++ b/src/goap/action_db.hpp @@ -4,15 +4,6 @@ #include "action.hpp" namespace goap { -struct StaticString { -private: - gd::String *ptr{nullptr}; -public: - StaticString() = default; - ~StaticString(); - gd::String &get(); -}; - /*! Global access to all (registered) Action types. * * Register them with the `register_action` function from `initialize_gdextension_types()`. @@ -29,7 +20,13 @@ public: * ``` */ class ActionDB { -protected: + struct StaticData { + StaticData() = default; + ~StaticData(); + gd::String &get_hint(); + gd::String *hint{nullptr}; + gd::Vector actions{}; + }; static ActionID register_action(Action *instance, gd::String name); public: /*! Get action by ID. @@ -47,8 +44,7 @@ public: return ActionDB::register_action(new TAction(), TAction::get_static_class()); } private: - static gd::Vector actions; - static StaticString hint; + static StaticData data; }; } diff --git a/src/goap/actor_world_state.cpp b/src/goap/actor_world_state.cpp index de2fba1..8c604f8 100644 --- a/src/goap/actor_world_state.cpp +++ b/src/goap/actor_world_state.cpp @@ -4,4 +4,12 @@ namespace goap { void ActorWorldState::_bind_methods() { #define CLASSNAME ActorWorldState } + +bool ActorWorldState::check_property_match(WorldProperty const &property) { + return this->get_world_property(property.key) == property.value; +} + +gd::Variant ActorWorldState::get_world_property(gd::String world_prop_name) { + return this->call("get_" + world_prop_name); +} } diff --git a/src/goap/actor_world_state.hpp b/src/goap/actor_world_state.hpp index 3b1a21f..5172fd4 100644 --- a/src/goap/actor_world_state.hpp +++ b/src/goap/actor_world_state.hpp @@ -15,6 +15,9 @@ typedef gd::KeyValue WorldProperty; class ActorWorldState : public gd::Node { GDCLASS(ActorWorldState, gd::Node); static void _bind_methods(); +public: + bool check_property_match(WorldProperty const &property); + gd::Variant get_world_property(gd::String world_prop_name); }; } diff --git a/src/goap/goal.cpp b/src/goap/goal.cpp new file mode 100644 index 0000000..49d0c3e --- /dev/null +++ b/src/goap/goal.cpp @@ -0,0 +1,27 @@ +#include "goal.hpp" +#include "utils/godot_macros.hpp" +#include "utils/util_functions.hpp" + +namespace goap { +void Goal::_bind_methods() { +#define CLASSNAME Goal + GDPROPERTY(requirements_dict, gd::Variant::DICTIONARY); + GDPROPERTY(desired_state_dict, gd::Variant::DICTIONARY); +} + +void Goal::set_requirements_dict(gd::Dictionary dict) { + this->requirements = utils::dictionary_to_hashmap(dict); +} + +gd::Dictionary Goal::get_requirements_dict() const { + return utils::hashmap_to_dictionary(this->requirements); +} + +void Goal::set_desired_state_dict(gd::Dictionary dict) { + this->desired_state = utils::dictionary_to_hashmap(dict); +} + +gd::Dictionary Goal::get_desired_state_dict() const { + return utils::hashmap_to_dictionary(this->desired_state); +} +} diff --git a/src/goap/goal.hpp b/src/goap/goal.hpp index 3ca39e3..93498ed 100644 --- a/src/goap/goal.hpp +++ b/src/goap/goal.hpp @@ -11,9 +11,11 @@ class Goal : public gd::Resource { GDCLASS(Goal, gd::Resource); static void _bind_methods(); public: - gd::Dictionary get_desired_state() const; - void set_desired_state(gd::Dictionary dict); -private: + void set_requirements_dict(gd::Dictionary dict); + gd::Dictionary get_requirements_dict() const; + void set_desired_state_dict(gd::Dictionary dict); + gd::Dictionary get_desired_state_dict() const; +public: //! Do not select this goal unless all requirements are met. WorldState requirements; //! The state to find a path to. diff --git a/src/goap/planner.cpp b/src/goap/planner.cpp index 4bc61b6..7a86268 100644 --- a/src/goap/planner.cpp +++ b/src/goap/planner.cpp @@ -1,34 +1,207 @@ #include "planner.hpp" -#include "goap/action_db.hpp" +#include "action_db.hpp" +#include "godot_cpp/classes/global_constants.hpp" +#include "godot_cpp/templates/hashfuncs.hpp" #include "utils/godot_macros.hpp" +#include +#include namespace goap { +WorldStateNode::WorldStateNode(WorldState const &goal, ActorWorldState *context) +: state{}, open_requirements{goal}, context{context} {} + +WorldStateNode::WorldStateNode(WorldStateNode const &last_state, Action const *last_action) +: state{last_state.state} +, open_requirements{last_state.open_requirements} +, last_action{last_action} +, context{last_state.context} { + for(WorldProperty const &req : last_action->get_required()) + this->open_requirements[req.key] = req.value; + for(WorldProperty const &effect : last_action->get_effects()) + if(this->open_requirements.has(effect.key)) + this->state[effect.key] = effect.value; +} + +int WorldStateNode::requirements_unmet() const { + int requirements = this->open_requirements.size(); + for(WorldProperty const &req : this->open_requirements) { + if(this->state.has(req.key) && this->state[req.key] == req.value) { + --requirements; + } else if(this->context->check_property_match(req)) { + --requirements; + } + } + return requirements; +} + +uint32_t WorldStateNodeHasher::hash(goap::WorldStateNode const &state) { + uint32_t hash{1}; + for(goap::WorldProperty const &prop : state.state) { + hash = gd::hash_murmur3_one_32(prop.key.hash(), hash); + hash = gd::hash_murmur3_one_32(prop.value.hash(), hash); + } + return gd::hash_fmix32(hash); +} + +bool operator==(goap::WorldStateNode const &lhs, goap::WorldStateNode const &rhs) { + if(lhs.state.size() != rhs.state.size()) + return false; + for(goap::WorldProperty const &prop_lhs : lhs.state) { + if((!rhs.state.has(prop_lhs.key)) || rhs.state[prop_lhs.key] != prop_lhs.value) + return false; + } + for(goap::WorldProperty const &prop_rhs : rhs.state) { + if((!lhs.state.has(prop_rhs.key)) || lhs.state[prop_rhs.key] != prop_rhs.value) + return false; + } + return true; +} + +bool operator!=(goap::WorldStateNode const &lhs, goap::WorldStateNode const &rhs) { + return !(lhs == rhs); +} + +bool operator<(goap::WorldStateNode const &lhs, goap::WorldStateNode const &rhs) { + return lhs.state.size() < rhs.state.size(); +} + +bool operator<=(goap::WorldStateNode const &lhs, goap::WorldStateNode const &rhs) { + return lhs.state.size() <= rhs.state.size(); +} + +bool operator>(goap::WorldStateNode const &lhs, goap::WorldStateNode const &rhs) { + return lhs.state.size() > rhs.state.size(); +} + +bool operator>=(goap::WorldStateNode const &lhs, goap::WorldStateNode const &rhs) { + return lhs.state.size() >= rhs.state.size(); +} + void Planner::_bind_methods() { #define CLASSNAME Planner - GDPROPERTY_HINTED(actions, gd::Variant::PACKED_INT32_ARRAY, gd::PROPERTY_HINT_ENUM, ActionDB::get_enum_hint()); + GDPROPERTY_HINTED(actions, gd::Variant::ARRAY, gd::PROPERTY_HINT_ARRAY_TYPE, gd::vformat("%s/%s:%s", gd::Variant::INT, gd::PROPERTY_HINT_ENUM, ActionDB::get_enum_hint())); + GDPROPERTY_HINTED(test_goal, gd::Variant::OBJECT, gd::PROPERTY_HINT_RESOURCE_TYPE, "Goal"); } -void Planner::_enter_tree() { +void Planner::_enter_tree() { GDGAMEONLY(); this->world_state = this->get_node("../ActorWorldState"); -} - -Plan Planner::plan_for_goal(gd::Ref goal) { - return {}; -} - -void Planner::set_actions(gd::PackedInt32Array array) { - this->actions.clear(); - for(int id : array) { - Action const *action = ActionDB::get_action(id); - if(action != nullptr && !this->actions.has(action)) - this->actions.push_back(action); + if(test_goal.is_valid()) { + Plan plan{this->plan_for_goal(test_goal)}; + gd::UtilityFunctions::print("plan for ", this->test_goal->get_path(), ":"); + for(Action const *action : plan) + gd::UtilityFunctions::print(" ", action->get_class()); } } -gd::PackedInt32Array Planner::get_actions() const { - gd::PackedInt32Array array{}; +Plan Planner::plan_for_goal(gd::Ref goal) { + // exit if goal reference is invalid or goal is already completed + if(!goal.is_valid()) { + gd::UtilityFunctions::print("goal not valid"); + return {}; + } + // The node representing the start of the search and the goal of the plan. + WorldStateNode goal_node{goal->desired_state, this->world_state}; + if(goal_node.requirements_unmet() == 0) { + gd::UtilityFunctions::print("goal ", goal->get_path(), " already met"); + return {}; + } + gd::Vector open{goal_node}; + NodeMap from{}; + NodeScoreMap best_path_cost{}; + NodeScoreMap heuristic_cost{}; + + best_path_cost.insert(goal_node, 0.f); + heuristic_cost.insert(goal_node, goal_node.requirements_unmet()); + + WorldStateNode current{goal_node}; + while(!open.is_empty()) { + current = open.get(0); + if(current.requirements_unmet() == 0) + return this->unroll_plan(current, from); + open.remove_at(0); + gd::Vector edges{this->get_neighbours(current)}; + gd::UtilityFunctions::print("found ", edges.size(), " possible actions"); + for(Action const * action : edges) { + WorldStateNode node_through{current, action}; + float const path_cost{best_path_cost.get(current) + 1.f}; + gd::UtilityFunctions::print("hashes ", WorldStateNodeHasher::hash(node_through), " ; ", WorldStateNodeHasher::hash(current)); + gd::UtilityFunctions::print("equiv: ", current == node_through); + if(!best_path_cost.has(node_through) || best_path_cost.get(node_through) >= path_cost) { + gd::UtilityFunctions::print("node through action ", action->get_class(), " added to open set"); + best_path_cost[node_through] = path_cost; + heuristic_cost[node_through] = path_cost + node_through.requirements_unmet(); + from[node_through] = current; + open.erase(node_through); + open.ordered_insert(node_through); + } else { + gd::UtilityFunctions::print("node through action ", action->get_class(), " scores worse than previous at same state. ", path_cost, " vs ", best_path_cost[node_through]); + } + } + } + + gd::UtilityFunctions::print("failed to find plan for goal ", goal->get_path()); + return {}; +} + +void Planner::set_actions(gd::Array array) { + this->actions.clear(); + for(size_t i = 0; i < array.size(); ++i) { + Action const *action = ActionDB::get_action(array[i]); + if(action != nullptr && !this->actions.has(action)) { + this->actions.push_back(action); + } + } +} + +gd::Array Planner::get_actions() const { + gd::Array array{}; for(Action const *action : this->actions) - action->get_id(); + array.push_back(action->get_id()); return array; } + +void Planner::set_test_goal(gd::Ref goal) { + this->test_goal = goal; +} + +gd::Ref Planner::get_test_goal() const { + return this->test_goal; +} + +gd::Vector Planner::get_neighbours(WorldStateNode const &from) const { + gd::Vector retval{}; + for(Action const *action : this->actions) { + if(!action->is_possible(from.context)) { + gd::UtilityFunctions::print("action ", action->get_class(), " is not possible"); + continue; + } + if(!this->does_action_contribute(action, from)) { + gd::UtilityFunctions::print("action ", action->get_class(), " does not contribute"); + continue; + } + retval.push_back(action); + } + return retval; +} + +bool Planner::does_action_contribute(Action const *action, WorldStateNode const &node) const { + WorldState const &effects = action->get_effects(); + for(WorldProperty const &goal : node.open_requirements) { + if(node.state.has(goal.key) && goal.value == node.state.get(goal.key)) + continue; // requirement already met, don't count as requiring contribution + if(effects.has(goal.key) && effects[goal.key] == goal.value) + return true; + } + return false; +} + +Plan Planner::unroll_plan(WorldStateNode const &start_node, NodeMap const &from) { + Plan plan{}; + WorldStateNode node{start_node}; + while(node.last_action != nullptr) { + plan.push_back(node.last_action); + node = from.get(node); + } + return plan; +} } diff --git a/src/goap/planner.hpp b/src/goap/planner.hpp index 41378ed..9db68f3 100644 --- a/src/goap/planner.hpp +++ b/src/goap/planner.hpp @@ -4,26 +4,74 @@ #include "action.hpp" #include "actor_world_state.hpp" #include "goal.hpp" -#include "godot_cpp/variant/packed_int32_array.hpp" -#include +#include #include +#include +#include namespace gd = godot; namespace goap { -typedef gd::Vector Plan; +typedef gd::Vector Plan; +/*! Wrapper for WorldState to be used to represent nodes in the A* implementation. + * Mainly required to let a HashMap of HashMaps work. + */ +struct WorldStateNode { + WorldStateNode() = default; + //! Root node constructor + WorldStateNode(WorldState const &goal, ActorWorldState *context); + //! Path node constructor + WorldStateNode(WorldStateNode const &last_state, Action const *last_action); + ~WorldStateNode() = default; + int requirements_unmet() const; + WorldState state{}; + WorldState open_requirements{}; + Action const *last_action{nullptr}; + ActorWorldState *context; +}; + +struct WorldStateNodeHasher { + static uint32_t hash(WorldStateNode const &state); +}; + +extern bool operator==(WorldStateNode const &lhs, WorldStateNode const &rhs); +extern bool operator!=(WorldStateNode const &lhs, WorldStateNode const &rhs); +extern bool operator<(WorldStateNode const &lhs, WorldStateNode const &rhs); +extern bool operator<=(WorldStateNode const &lhs, WorldStateNode const &rhs); +extern bool operator>(WorldStateNode const &lhs, WorldStateNode const &rhs); +extern bool operator>=(WorldStateNode const &lhs, WorldStateNode const &rhs); + +typedef gd::HashMap NodeMap; +typedef gd::HashMap NodeScoreMap; + +/*! Uses A* to compute `Action` sequences for an actor to achieve `Goal`s. + * + * Set up available actions from the editor and call plan_for_goal with a desired goal. + * Requires a sibling node inheriting from ActorWorldState called "ActorWorldState". + */ class Planner : public gd::Node { GDCLASS(Planner, gd::Node); static void _bind_methods(); public: virtual void _enter_tree() override; + //! Compute a plan to achieve the passed `Goal`. Plan plan_for_goal(gd::Ref goal); - void set_actions(gd::PackedInt32Array array); - gd::PackedInt32Array get_actions() const; + void set_actions(gd::Array array); + gd::Array get_actions() const; + void set_test_goal(gd::Ref goal); + gd::Ref get_test_goal() const; private: + //! \returns A vector of all actions that satisfy any of the requirements in `unsatisfied` + gd::Vector get_neighbours(WorldStateNode const &from) const; + //! \returns True if the passed action contributes to any of the open requirements in `node`. + bool does_action_contribute(Action const *action, WorldStateNode const &node) const; + //! \returns A plan starting with `start_node` traced backwards through the `from` map. + Plan unroll_plan(WorldStateNode const &start_node, NodeMap const &from); +private: + gd::Ref test_goal{}; ActorWorldState *world_state{nullptr}; gd::Vector actions; }; diff --git a/src/register_types.cpp b/src/register_types.cpp index 68eaf35..f8200ae 100644 --- a/src/register_types.cpp +++ b/src/register_types.cpp @@ -2,6 +2,7 @@ #include "goap/action.hpp" #include "goap/action_db.hpp" #include "goap/actor_world_state.hpp" +#include "goap/goal.hpp" #include "goap/planner.hpp" #include "rts_game_mode.hpp" #include "rts_player.hpp" @@ -20,12 +21,17 @@ void initialize_gdextension_types(ModuleInitializationLevel p_level) return; } utils::godot_cpp_utils_register_types(); + + // always register actions before classes, + // so that ActionDB::get_enum_hint is complete before _bind_methods + ClassDB::register_class(); ClassDB::register_class(); ClassDB::register_class(); + ClassDB::register_class(); + ClassDB::register_class(); ClassDB::register_class(); - goap::ActionDB::register_action(); } extern "C"