feat: implemented goal oriented action planning

This commit is contained in:
Sara 2024-06-04 21:42:09 +02:00
parent 092ea840dc
commit 1ddfa17659
11 changed files with 360 additions and 100 deletions

View file

@ -1,11 +1,24 @@
#include "action.hpp" #include "action.hpp"
namespace goap { namespace goap {
Action::Action(WorldState &requires, WorldState &effects) Action::~Action() {}
: requires{requires}, effects{effects} {}
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 { WorldState const &Action::get_required() const {
return this->requires; return this->required;
} }
WorldState const &Action::get_effects() const { WorldState const &Action::get_effects() const {
@ -16,22 +29,11 @@ ActionID Action::get_id() const {
return this->id; return this->id;
} }
ActionData::ActionData(Action *action) bool Action::procedural_is_possible(ActorWorldState *context) const {
: data{action} {}
ActionData::~ActionData() {
if(this->data != nullptr)
delete this->data;
}
}
bool TestAction::is_done_for(goap::ActorWorldState *) {
return true; return true;
} }
bool TestAction::is_possible_for(goap::ActorWorldState *) {
bool Action::procedural_is_completed(ActorWorldState *context) const {
return true; return true;
} }
goap::State *TestAction::get_apply_state() const {
return nullptr;
} }

View file

@ -10,7 +10,7 @@
#define GOAP_ACTION(Name_) \ #define GOAP_ACTION(Name_) \
public: \ public: \
_FORCE_INLINE_ static gd::String get_static_class() { return #Name_; }\ _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: private:
namespace goap { namespace goap {
@ -22,44 +22,33 @@ typedef int ActionID;
*/ */
class Action { class Action {
friend class ActionDB; friend class ActionDB;
GOAP_ACTION(Action);
public: 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 ~Action();
virtual bool is_done_for(ActorWorldState *state) = 0;
virtual State *get_apply_state() const = 0; 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_required() const;
WorldState const &get_effects() const; WorldState const &get_effects() const;
ActionID get_id() const; ActionID get_id() const;
protected: protected:
Action() = default; Action() = default;
Action(WorldState &requires, WorldState &effects);
private:
WorldState requires{};
WorldState effects{};
ActionID id{-1};
};
/* Storage wrapper for managed action class. virtual bool procedural_is_possible(ActorWorldState *context) const;
*/ virtual bool procedural_is_completed(ActorWorldState *context) const;
struct ActionData { protected:
ActionData() = default; WorldState blocking_required{};
ActionData(Action *action); WorldState required{};
~ActionData(); WorldState effects{};
Action *data{nullptr}; 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 #endif // !GOAP_ACTION_HPP

View file

@ -1,36 +1,42 @@
#include "action_db.hpp" #include "action_db.hpp"
#include "godot_cpp/variant/utility_functions.hpp"
namespace goap { namespace goap {
StaticString::~StaticString() { ActionDB::StaticData::~StaticData() {
if(this->ptr != nullptr) if(this->hint != nullptr)
delete this->ptr; delete this->hint;
for(Action *action : this->actions)
delete action;
} }
gd::String &StaticString::get() { gd::String &ActionDB::StaticData::get_hint() {
if(this->ptr == nullptr) if(this->hint == nullptr)
this->ptr = new gd::String(); this->hint = new gd::String();
return *this->ptr; return *this->hint;
} }
ActionID ActionDB::register_action(Action *instance, gd::String name) { ActionID ActionDB::register_action(Action *instance, gd::String name) {
instance->id = ActionDB::actions.size(); instance->id = ActionDB::data.actions.size();
if(!ActionDB::hint.get().is_empty()) if(!ActionDB::data.get_hint().is_empty())
ActionDB::hint.get() += ","; ActionDB::data.get_hint() += ",";
ActionDB::hint.get() += name; ActionDB::data.get_hint() += name;
ActionDB::actions.push_back(ActionData(instance)); // 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(); return instance->get_id();
} }
Action const *ActionDB::get_action(ActionID index) { 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 nullptr;
return ActionDB::actions[index].data; }
return ActionDB::data.actions[index];
} }
gd::String const &ActionDB::get_enum_hint() { gd::String const &ActionDB::get_enum_hint() {
return ActionDB::hint.get(); return ActionDB::data.get_hint();
} }
gd::Vector<ActionData> ActionDB::actions{gd::Vector<ActionData>()}; ActionDB::StaticData ActionDB::data{};
StaticString ActionDB::hint{};
} }

View file

@ -4,15 +4,6 @@
#include "action.hpp" #include "action.hpp"
namespace goap { namespace goap {
struct StaticString {
private:
gd::String *ptr{nullptr};
public:
StaticString() = default;
~StaticString();
gd::String &get();
};
/*! Global access to all (registered) Action types. /*! Global access to all (registered) Action types.
* *
* Register them with the `register_action` function from `initialize_gdextension_types()`. * Register them with the `register_action` function from `initialize_gdextension_types()`.
@ -29,7 +20,13 @@ public:
* ``` * ```
*/ */
class ActionDB { class ActionDB {
protected: struct StaticData {
StaticData() = default;
~StaticData();
gd::String &get_hint();
gd::String *hint{nullptr};
gd::Vector<Action *> actions{};
};
static ActionID register_action(Action *instance, gd::String name); static ActionID register_action(Action *instance, gd::String name);
public: public:
/*! Get action by ID. /*! Get action by ID.
@ -47,8 +44,7 @@ public:
return ActionDB::register_action(new TAction(), TAction::get_static_class()); return ActionDB::register_action(new TAction(), TAction::get_static_class());
} }
private: private:
static gd::Vector<ActionData> actions; static StaticData data;
static StaticString hint;
}; };
} }

View file

@ -4,4 +4,12 @@ namespace goap {
void ActorWorldState::_bind_methods() { void ActorWorldState::_bind_methods() {
#define CLASSNAME ActorWorldState #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);
}
} }

View file

@ -15,6 +15,9 @@ typedef gd::KeyValue<gd::StringName, gd::Variant> WorldProperty;
class ActorWorldState : public gd::Node { class ActorWorldState : public gd::Node {
GDCLASS(ActorWorldState, gd::Node); GDCLASS(ActorWorldState, gd::Node);
static void _bind_methods(); static void _bind_methods();
public:
bool check_property_match(WorldProperty const &property);
gd::Variant get_world_property(gd::String world_prop_name);
}; };
} }

27
src/goap/goal.cpp Normal file
View file

@ -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<gd::StringName, gd::Variant>(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<gd::StringName, gd::Variant>(dict);
}
gd::Dictionary Goal::get_desired_state_dict() const {
return utils::hashmap_to_dictionary(this->desired_state);
}
}

View file

@ -11,9 +11,11 @@ class Goal : public gd::Resource {
GDCLASS(Goal, gd::Resource); GDCLASS(Goal, gd::Resource);
static void _bind_methods(); static void _bind_methods();
public: public:
gd::Dictionary get_desired_state() const; void set_requirements_dict(gd::Dictionary dict);
void set_desired_state(gd::Dictionary dict); gd::Dictionary get_requirements_dict() const;
private: 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. //! Do not select this goal unless all requirements are met.
WorldState requirements; WorldState requirements;
//! The state to find a path to. //! The state to find a path to.

View file

@ -1,34 +1,207 @@
#include "planner.hpp" #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 "utils/godot_macros.hpp"
#include <cstdint>
#include <godot_cpp/variant/utility_functions.hpp>
namespace goap { 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() { void Planner::_bind_methods() {
#define CLASSNAME Planner #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>("../ActorWorldState"); this->world_state = this->get_node<ActorWorldState>("../ActorWorldState");
} if(test_goal.is_valid()) {
Plan plan{this->plan_for_goal(test_goal)};
Plan Planner::plan_for_goal(gd::Ref<Goal> goal) { gd::UtilityFunctions::print("plan for ", this->test_goal->get_path(), ":");
return {}; for(Action const *action : plan)
} gd::UtilityFunctions::print(" ", action->get_class());
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);
} }
} }
gd::PackedInt32Array Planner::get_actions() const { Plan Planner::plan_for_goal(gd::Ref<Goal> goal) {
gd::PackedInt32Array array{}; // 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<WorldStateNode> 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<Action const *> 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) for(Action const *action : this->actions)
action->get_id(); array.push_back(action->get_id());
return array; return array;
} }
void Planner::set_test_goal(gd::Ref<Goal> goal) {
this->test_goal = goal;
}
gd::Ref<Goal> Planner::get_test_goal() const {
return this->test_goal;
}
gd::Vector<Action const *> Planner::get_neighbours(WorldStateNode const &from) const {
gd::Vector<Action const *> 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;
}
} }

View file

@ -4,26 +4,74 @@
#include "action.hpp" #include "action.hpp"
#include "actor_world_state.hpp" #include "actor_world_state.hpp"
#include "goal.hpp" #include "goal.hpp"
#include "godot_cpp/variant/packed_int32_array.hpp" #include <cstdint>
#include <godot_cpp/templates/vector.hpp>
#include <godot_cpp/classes/node.hpp> #include <godot_cpp/classes/node.hpp>
#include <godot_cpp/templates/vector.hpp>
#include <godot_cpp/variant/packed_int32_array.hpp>
namespace gd = godot; namespace gd = godot;
namespace goap { namespace goap {
typedef gd::Vector<Action> Plan; typedef gd::Vector<Action const *> 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<WorldStateNode, WorldStateNode, WorldStateNodeHasher> NodeMap;
typedef gd::HashMap<WorldStateNode, float, WorldStateNodeHasher> 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 { class Planner : public gd::Node {
GDCLASS(Planner, gd::Node); GDCLASS(Planner, gd::Node);
static void _bind_methods(); static void _bind_methods();
public: public:
virtual void _enter_tree() override; virtual void _enter_tree() override;
//! Compute a plan to achieve the passed `Goal`.
Plan plan_for_goal(gd::Ref<Goal> goal); Plan plan_for_goal(gd::Ref<Goal> goal);
void set_actions(gd::PackedInt32Array array); void set_actions(gd::Array array);
gd::PackedInt32Array get_actions() const; gd::Array get_actions() const;
void set_test_goal(gd::Ref<Goal> goal);
gd::Ref<Goal> get_test_goal() const;
private: private:
//! \returns A vector of all actions that satisfy any of the requirements in `unsatisfied`
gd::Vector<Action const *> 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<Goal> test_goal{};
ActorWorldState *world_state{nullptr}; ActorWorldState *world_state{nullptr};
gd::Vector<Action const *> actions; gd::Vector<Action const *> actions;
}; };

View file

@ -2,6 +2,7 @@
#include "goap/action.hpp" #include "goap/action.hpp"
#include "goap/action_db.hpp" #include "goap/action_db.hpp"
#include "goap/actor_world_state.hpp" #include "goap/actor_world_state.hpp"
#include "goap/goal.hpp"
#include "goap/planner.hpp" #include "goap/planner.hpp"
#include "rts_game_mode.hpp" #include "rts_game_mode.hpp"
#include "rts_player.hpp" #include "rts_player.hpp"
@ -20,12 +21,17 @@ void initialize_gdextension_types(ModuleInitializationLevel p_level)
return; return;
} }
utils::godot_cpp_utils_register_types(); 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<RTSPlayer>(); ClassDB::register_class<RTSPlayer>();
ClassDB::register_class<Unit>(); ClassDB::register_class<Unit>();
ClassDB::register_class<RTSGameMode>(); ClassDB::register_class<RTSGameMode>();
ClassDB::register_class<goap::ActorWorldState>(); ClassDB::register_class<goap::ActorWorldState>();
ClassDB::register_class<goap::Goal>();
ClassDB::register_class<goap::Planner>(); ClassDB::register_class<goap::Planner>();
goap::ActionDB::register_action<TestAction>();
} }
extern "C" extern "C"