From ad716318098367937e915d2095ad92578a994c99 Mon Sep 17 00:00:00 2001 From: "Zed A. Shaw" Date: Fri, 14 Mar 2025 11:14:25 -0400 Subject: [PATCH] Enemies and now using the GOAP AI to decide when to attack the player, but it's very rough right now. I need to sort out how to store the AI states and use them in the System. --- Makefile | 4 ++-- ai.cpp | 1 + assets/ai.json | 9 +++++++-- assets/enemies.json | 8 ++++---- autowalker.cpp | 2 +- components.hpp | 9 ++++++++- dinkyecs.hpp | 1 + goap.cpp | 9 +++++---- goap.hpp | 4 +++- gui_fsm.cpp | 1 + systems.cpp | 38 +++++++++++++++++++++++++------------- systems.hpp | 1 + tests/ai.cpp | 4 ++-- 13 files changed, 61 insertions(+), 30 deletions(-) diff --git a/Makefile b/Makefile index 4d629dc..6144bd3 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ tracy_build: meson compile -j 10 -C builddir test: build - ./builddir/runtests "[ai]" + ./builddir/runtests run: build test powershell "cp ./builddir/zedcaster.exe ." @@ -41,7 +41,7 @@ clean: meson compile --clean -C builddir debug_test: build - gdb --nx -x .gdbinit --ex run --args builddir/runtests.exe -e "[ai]" + gdb --nx -x .gdbinit --ex run --args builddir/runtests.exe -e win_installer: powershell 'start "C:\Program Files (x86)\solicus\InstallForge\bin\ifbuilderenvx86.exe" win_installer.ifp' diff --git a/ai.cpp b/ai.cpp index 6102618..c56a396 100644 --- a/ai.cpp +++ b/ai.cpp @@ -18,6 +18,7 @@ namespace ai { Action config_action(AIProfile& profile, nlohmann::json& config) { check(config.contains("name"), "config_action: action config missing name"); check(config.contains("cost"), "config_action: action config missing cost"); + check(config["cost"] < STATE_MAX, "config_action: action cost is greater than STATE_MAX"); Action result(config["name"], config["cost"]); diff --git a/assets/ai.json b/assets/ai.json index 2facfd9..62dfa3d 100644 --- a/assets/ai.json +++ b/assets/ai.json @@ -7,13 +7,15 @@ "no_more_enemies": 4, "in_combat": 5, "have_item": 6, - "have_healing": 7 + "have_healing": 7, + "detect_enemy": 8 }, "actions": [ { "name": "find_enemy", "cost": 5, "needs": { + "detect_enemy": true, "in_combat": false, "no_more_enemies": false, "enemy_found": false @@ -68,7 +70,8 @@ "no_more_enemies": false, "in_combat": false, "have_item": false, - "have_healing": false + "have_healing": false, + "detect_enemy": true }, "Walker::final_state": { "enemy_found": true, @@ -79,12 +82,14 @@ "no_more_enemies": true }, "Enemy::initial_state": { + "detect_enemy": false, "enemy_found": false, "enemy_dead": false, "health_good": true, "in_combat": false }, "Enemy::final_state": { + "detect_enemy": true, "enemy_found": true, "enemy_dead": true, "health_good": true diff --git a/assets/enemies.json b/assets/enemies.json index ca46990..cf673ba 100644 --- a/assets/enemies.json +++ b/assets/enemies.json @@ -19,7 +19,7 @@ }, {"_type": "Combat", "hp": 20, "max_hp": 20, "damage": 1, "dead": false}, {"_type": "Motion", "dx": 0, "dy": 0, "random": false}, - {"_type": "EnemyConfig", "hearing_distance": 5}, + {"_type": "EnemyConfig", "hearing_distance": 5, "ai_script": "Enemy::actions", "ai_start_name": "Enemy::initial_state", "ai_goal_name": "Enemy::final_state"}, {"_type": "Animation", "easing": 1, "ease_rate": 0.2, "scale": 0.1, "simple": true, "frames": 10, "speed": 0.3, "stationary": false}, {"_type": "Sprite", "name": "armored_knight", "width": 256, "height": 256, "width": 256, "height": 256, "scale": 1.0}, {"_type": "Sound", "attack": "Sword_Hit_2", "death": "Humanoid_Death_1"} @@ -33,7 +33,7 @@ }, {"_type": "Combat", "hp": 40, "max_hp": 40, "damage": 10, "dead": false}, {"_type": "Motion", "dx": 0, "dy": 0, "random": true}, - {"_type": "EnemyConfig", "hearing_distance": 5}, + {"_type": "EnemyConfig", "hearing_distance": 5, "ai_script": "Enemy::actions", "ai_start_name": "Enemy::initial_state", "ai_goal_name": "Enemy::final_state"}, {"_type": "Sprite", "name": "axe_ranger", "width": 256, "height": 256, "scale": 1.0}, {"_type": "Animation", "easing": 3, "ease_rate": 0.5, "scale": 0.1, "simple": false, "frames": 2, "speed": 0.6, "stationary": false}, {"_type": "Sound", "attack": "Sword_Hit_2", "death": "Ranger_1"} @@ -47,7 +47,7 @@ }, {"_type": "Combat", "hp": 20, "max_hp": 20, "damage": 20, "dead": false}, {"_type": "Motion", "dx": 0, "dy": 0, "random": false}, - {"_type": "EnemyConfig", "hearing_distance": 10}, + {"_type": "EnemyConfig", "hearing_distance": 10, "ai_script": "Enemy::actions", "ai_start_name": "Enemy::initial_state", "ai_goal_name": "Enemy::final_state"}, {"_type": "Animation", "easing": 3, "ease_rate": 0.5, "scale": 0.1, "simple": true, "frames": 10, "speed": 1.0, "stationary": false}, {"_type": "Sprite", "name": "rat_with_sword", "width": 256, "height": 256, "scale": 1.0}, {"_type": "Sound", "attack": "Small_Rat", "death": "Creature_Death_1"} @@ -61,7 +61,7 @@ }, {"_type": "Combat", "hp": 20, "max_hp": 20, "damage": 20, "dead": false}, {"_type": "Motion", "dx": 0, "dy": 0, "random": false}, - {"_type": "EnemyConfig", "hearing_distance": 10}, + {"_type": "EnemyConfig", "hearing_distance": 10, "ai_script": "Enemy::actions", "ai_start_name": "Enemy::initial_state", "ai_goal_name": "Enemy::final_state"}, {"_type": "Animation", "easing": 2, "ease_rate": 0.5, "scale": 0.1, "simple": true, "frames": 10, "speed": 1.0, "stationary": false}, {"_type": "Sprite", "name": "hairy_spider", "width": 256, "height": 256, "scale": 1.0}, {"_type": "Sound", "attack": "Spider_1", "death": "Spider_2"} diff --git a/autowalker.cpp b/autowalker.cpp index 983beed..3c4641b 100644 --- a/autowalker.cpp +++ b/autowalker.cpp @@ -222,7 +222,7 @@ void Autowalker::handle_boss_fight() { void Autowalker::handle_player_walk(ai::State& start, ai::State& goal) { start = update_state(start); auto a_plan = ai::plan("Walker::actions", start, goal); - dump_script("\n\n\n-----WALKER SCRIPT", start, a_plan.script); + ai::dump_script("\n\n\n-----WALKER SCRIPT", start, a_plan.script); auto action = a_plan.script.front(); if(action.name == "find_enemy") { diff --git a/components.hpp b/components.hpp index 8353c39..39b0912 100644 --- a/components.hpp +++ b/components.hpp @@ -9,6 +9,7 @@ #include #include "easings.hpp" #include "json_mods.hpp" +#include "goap.hpp" namespace components { @@ -45,6 +46,11 @@ namespace components { struct EnemyConfig { int hearing_distance = 10; + std::string ai_script; + std::string ai_start_name; + std::string ai_goal_name; + ai::State ai_start; + ai::State ai_goal; }; struct Debug { @@ -138,7 +144,8 @@ namespace components { ENROLL_COMPONENT(Weapon, damage); ENROLL_COMPONENT(Loot, amount); ENROLL_COMPONENT(Position, location.x, location.y); - ENROLL_COMPONENT(EnemyConfig, hearing_distance); + ENROLL_COMPONENT(EnemyConfig, hearing_distance, + ai_script, ai_start_name, ai_goal_name); ENROLL_COMPONENT(Motion, dx, dy, random); ENROLL_COMPONENT(Combat, hp, max_hp, damage, dead); ENROLL_COMPONENT(Device, config, events); diff --git a/dinkyecs.hpp b/dinkyecs.hpp index a282823..1b568fc 100644 --- a/dinkyecs.hpp +++ b/dinkyecs.hpp @@ -198,6 +198,7 @@ namespace DinkyECS return !queue.empty(); } + /* std::optional can't do references. Don't try it! */ template std::optional get_if(DinkyECS::Entity entity) { if(has(entity)) { diff --git a/goap.cpp b/goap.cpp index a6736b6..45fdf69 100644 --- a/goap.cpp +++ b/goap.cpp @@ -46,9 +46,9 @@ namespace ai { return (state | $positive_effects) & ~$negative_effects; } - int distance_to_goal(State from, State to, Action& action) { + int distance_to_goal(State from, State to) { auto result = from ^ to; - return result.count() + action.cost; + return result.count(); } Script reconstruct_path(std::unordered_map& came_from, Action& current) { @@ -65,11 +65,12 @@ namespace ai { } inline int h(State start, State goal, Action& action) { - return distance_to_goal(start, goal, action); + (void)action; // not sure if cost goes here or on d() + return distance_to_goal(start, goal); } inline int d(State start, State goal, Action& action) { - return distance_to_goal(start, goal, action); + return distance_to_goal(start, goal) + action.cost; } ActionState find_lowest(std::unordered_map& open_set) { diff --git a/goap.hpp b/goap.hpp index e01234f..623f064 100644 --- a/goap.hpp +++ b/goap.hpp @@ -30,6 +30,8 @@ namespace ai { State $positive_effects; State $negative_effects; + Action() {} + Action(std::string name, int cost) : name(name), cost(cost) { } @@ -68,7 +70,7 @@ namespace ai { bool is_subset(State& source, State& target); - int distance_to_goal(State from, State to, Action& action); + int distance_to_goal(State from, State to); ActionPlan plan_actions(std::vector& actions, State start, State goal); } diff --git a/gui_fsm.cpp b/gui_fsm.cpp index 6b0d84a..6e2c036 100644 --- a/gui_fsm.cpp +++ b/gui_fsm.cpp @@ -340,6 +340,7 @@ namespace gui { } void FSM::run_systems() { + System::enemy_ai($level); System::enemy_pathing($level); System::collision($level); System::motion($level); diff --git a/systems.cpp b/systems.cpp index a6ecd3b..d2f11e9 100644 --- a/systems.cpp +++ b/systems.cpp @@ -9,6 +9,8 @@ #include "inventory.hpp" #include "events.hpp" #include "sound.hpp" +#include "ai.hpp" +#include "ai_debug.hpp" using std::string; using namespace fmt; @@ -35,10 +37,26 @@ void System::lighting(GameLevel &level) { } void System::enemy_ai(GameLevel &level) { - (void)level; - // AI: look up Enemy::actions in ai.json - // AI: setup the state - // AI: process it and keep the next action in the world + auto &world = *level.world; + auto &map = *level.map; + auto player = world.get_the(); + const auto &player_position = world.get(player.entity); + map.set_target(player_position.location); + map.make_paths(); + + world.query([&](const auto ent, auto& pos, auto& config) { + config.ai_start = ai::load_state(config.ai_start_name); + config.ai_goal = ai::load_state(config.ai_goal_name); + + ai::set(config.ai_start, "detect_enemy", + map.distance(pos.location) < config.hearing_distance); + + auto a_plan = ai::plan(config.ai_script, config.ai_start, config.ai_goal); + + ai::dump_script("\n\n\n-----ENEMY SCRIPT", config.ai_start, a_plan.script); + auto action = a_plan.script.front(); + world.set(ent, action); + }); } void System::enemy_pathing(GameLevel &level) { @@ -52,15 +70,9 @@ void System::enemy_pathing(GameLevel &level) { world.query([&](auto ent, auto &position, auto &motion) { if(ent != player.entity) { - // AI: EnemyConfig can be replaced with an AI thing - // AI: after the enemy_ai systems are run we can then look at what - // AI: their next actions is, and if it's pathing do that - - dbc::check(world.has(ent), "enemy is missing config"); - const auto &config = world.get(ent); - - Point out = position.location; // copy - if(map.distance(out) < config.hearing_distance) { + auto action = world.get_if(ent); + if(action && (*action).name == "find_enemy") { + Point out = position.location; // copy map.neighbors(out, motion.random); motion = { int(out.x - position.location.x), int(out.y - position.location.y)}; } diff --git a/systems.hpp b/systems.hpp index 06c6a9d..38c1853 100644 --- a/systems.hpp +++ b/systems.hpp @@ -19,5 +19,6 @@ namespace System { void plan_motion(DinkyECS::World& world, Point move_to); void draw_entities(DinkyECS::World &world, Map &map, const Matrix &lights, ftxui::Canvas &canvas, const Point &cam_orig, size_t view_x, size_t view_y); + void enemy_ai(GameLevel &level); void combat(GameLevel &level); } diff --git a/tests/ai.cpp b/tests/ai.cpp index ae1a233..a556e36 100644 --- a/tests/ai.cpp +++ b/tests/ai.cpp @@ -36,7 +36,7 @@ TEST_CASE("state and actions work", "[ai]") { // start is clean but after move is dirty REQUIRE(move_closer.can_effect(start)); REQUIRE(!move_closer.can_effect(after_move_state)); - REQUIRE(ai::distance_to_goal(start, after_move_state, move_closer) == 11); + REQUIRE(ai::distance_to_goal(start, after_move_state) == 1); ai::Action kill_it("kill_it", 10); kill_it.needs(ENEMY_IN_RANGE, true); @@ -48,7 +48,7 @@ TEST_CASE("state and actions work", "[ai]") { auto after_kill_state = kill_it.apply_effect(after_move_state); REQUIRE(!kill_it.can_effect(after_kill_state)); - REQUIRE(ai::distance_to_goal(after_move_state, after_kill_state, kill_it) == 11); + REQUIRE(ai::distance_to_goal(after_move_state, after_kill_state) == 1); kill_it.ignore(ENEMY_IN_RANGE); REQUIRE(kill_it.can_effect(after_move_state));