Now have the basics of the turn based battle engine with AI rebellion working.

This commit is contained in:
Zed A. Shaw 2025-12-01 00:14:08 -05:00
parent f3b20f30c5
commit c78b2ae75e
8 changed files with 114 additions and 73 deletions

View file

@ -3,34 +3,41 @@
namespace combat { namespace combat {
void BattleEngine::add_enemy(Combatant enemy) { void BattleEngine::add_enemy(Combatant enemy) {
combatants.try_emplace(enemy.entity, enemy); $combatants.try_emplace(enemy.entity, enemy);
}
void BattleEngine::player_request(const std::string& request) {
$player_requests.emplace(request);
} }
bool BattleEngine::plan() { bool BattleEngine::plan() {
dbc::check($player_requests.size() > 0, "Calling plan without any player reqeusts queued.");
using enum BattleHostState;
int active = 0; int active = 0;
for(auto& [entity, enemy] : combatants) { for(auto& [entity, enemy] : $combatants) {
enemy.ai->update(); enemy.ai->update();
active += enemy.ai->active(); active += enemy.ai->active();
if(enemy.ai->active()) { if(enemy.ai->active()) {
for(auto& action : enemy.ai->plan.script) { for(auto& action : enemy.ai->plan.script) {
if(enemy.ai->wants_to("kill_enemy")) { BattleHostState host_state = not_host;
pending_actions.emplace_back(enemy, action, BattleAction::ATTACK);
} else if(enemy.ai->wants_to("run_away")) { if(enemy.is_host) {
pending_actions.emplace_back(enemy, action, BattleAction::ESCAPE); host_state = $player_requests.contains(action.name) ? agree : disagree;
} else {
pending_actions.emplace_back(enemy, action, BattleAction::OTHER);
} }
$pending_actions.emplace_back(enemy, action.name, action.cost, host_state);
} }
} }
} }
if(pending_actions.size() > 0) { if($pending_actions.size() > 0) {
std::sort(pending_actions.begin(), pending_actions.end(), std::sort($pending_actions.begin(), $pending_actions.end(),
[](const auto& a, const auto& b) -> bool [](const auto& a, const auto& b) -> bool
{ {
return a.wants_to.cost > b.wants_to.cost; return a.cost > b.cost;
}); });
} }
@ -38,35 +45,35 @@ namespace combat {
} }
std::optional<BattleResult> BattleEngine::next() { std::optional<BattleResult> BattleEngine::next() {
if(pending_actions.size() == 0) return std::nullopt; if($pending_actions.size() == 0) return std::nullopt;
auto ba = pending_actions.back(); auto ba = $pending_actions.back();
pending_actions.pop_back(); $pending_actions.pop_back();
return std::make_optional(ba); return std::make_optional(ba);
} }
void BattleEngine::dump() { void BattleEngine::dump() {
for(auto& [entity, enemy] : combatants) { for(auto& [entity, enemy] : $combatants) {
fmt::println("\n\n###### ENTITY #{}", entity); fmt::println("\n\n###### ENTITY #{}", entity);
enemy.ai->dump(); enemy.ai->dump();
} }
} }
void BattleEngine::set(DinkyECS::Entity entity, const std::string& state, bool setting) { void BattleEngine::set(DinkyECS::Entity entity, const std::string& state, bool setting) {
dbc::check(combatants.contains(entity), "invalid combatant given to BattleEngine"); dbc::check($combatants.contains(entity), "invalid combatant given to BattleEngine");
auto& action = combatants.at(entity); auto& action = $combatants.at(entity);
action.ai->set_state(state, setting); action.ai->set_state(state, setting);
} }
void BattleEngine::set_all(const std::string& state, bool setting) { void BattleEngine::set_all(const std::string& state, bool setting) {
for(auto& [ent, action] : combatants) { for(auto& [ent, action] : $combatants) {
action.ai->set_state(state, setting); action.ai->set_state(state, setting);
} }
} }
Combatant& BattleEngine::get_enemy(DinkyECS::Entity entity) { Combatant& BattleEngine::get_enemy(DinkyECS::Entity entity) {
dbc::check(combatants.contains(entity), "invalid combatant given to BattleEngine"); dbc::check($combatants.contains(entity), "invalid combatant given to BattleEngine");
return combatants.at(entity); return $combatants.at(entity);
} }
} }

View file

@ -4,28 +4,34 @@
#include "dinkyecs.hpp" #include "dinkyecs.hpp"
#include <optional> #include <optional>
#include "components.hpp" #include "components.hpp"
#include <set>
namespace combat { namespace combat {
enum class BattleHostState {
not_host = 0,
agree = 1,
disagree = 2
};
struct Combatant { struct Combatant {
DinkyECS::Entity entity = DinkyECS::NONE; DinkyECS::Entity entity = DinkyECS::NONE;
ai::EntityAI* ai = nullptr; ai::EntityAI* ai = nullptr;
components::Combat* combat = nullptr; components::Combat* combat = nullptr;
}; bool is_host=false;
enum class BattleAction {
ATTACK, BLOCK, ESCAPE, OTHER
}; };
struct BattleResult { struct BattleResult {
Combatant state; Combatant state;
ai::Action wants_to; std::string wants_to;
BattleAction action; int cost;
BattleHostState host_state;
}; };
struct BattleEngine { struct BattleEngine {
std::unordered_map<DinkyECS::Entity, Combatant> combatants; std::unordered_map<DinkyECS::Entity, Combatant> $combatants;
std::vector<BattleResult> pending_actions; std::vector<BattleResult> $pending_actions;
std::set<std::string> $player_requests;
void add_enemy(Combatant ba); void add_enemy(Combatant ba);
Combatant& get_enemy(DinkyECS::Entity entity); Combatant& get_enemy(DinkyECS::Entity entity);
@ -34,5 +40,6 @@ namespace combat {
void dump(); void dump();
void set(DinkyECS::Entity entity, const std::string& state, bool setting); void set(DinkyECS::Entity entity, const std::string& state, bool setting);
void set_all(const std::string& state, bool setting); void set_all(const std::string& state, bool setting);
void player_request(const std::string& request);
}; };
} }

View file

@ -70,6 +70,8 @@ namespace boss {
$ui.status(L"PLAYER TURN"); $ui.status(L"PLAYER TURN");
state(State::PLAYER_TURN); state(State::PLAYER_TURN);
break; break;
case TICK:
break; // ignore tick
default: default:
fmt::println("BOSS_FIGHT:START unknown event {}", (int)ev); fmt::println("BOSS_FIGHT:START unknown event {}", (int)ev);
break; break;
@ -98,9 +100,11 @@ namespace boss {
$ui.update_stats(); $ui.update_stats();
state(State::PLAYER_TURN); state(State::PLAYER_TURN);
} break; } break;
case TICK:
break; // ignore tick
default: default:
fmt::println("BOSS_FIGHT:BOSS_TURN unknown event {}", (int)ev);
break; break;
// skip it
} }
} }
@ -124,8 +128,10 @@ namespace boss {
boss::System::combat($world, $boss_id, attack_id); boss::System::combat($world, $boss_id, attack_id);
state(State::BOSS_TURN); state(State::BOSS_TURN);
} break; } break;
case TICK:
break; // ignore tick
default: default:
// skip it fmt::println("BOSS_FIGHT:PLAYER_TURN unknown event {}", (int)ev);
break; break;
} }
} }

View file

@ -51,34 +51,51 @@ namespace boss {
auto& level = GameDB::current_level(); auto& level = GameDB::current_level();
dbc::check(world->has<ai::EntityAI>(boss_id), "boss doesn't have an AI"); dbc::check(world->has<ai::EntityAI>(boss_id), "boss doesn't have an AI");
auto host_start = ai::load_state("Host::initial_state");
auto host_goal = ai::load_state("Host::final_state");
ai::EntityAI host_ai("Host::actions", host_start, host_goal);
auto& player_combat = world->get<Combat>(level.player); auto& player_combat = world->get<Combat>(level.player);
auto& boss_combat = world->get<Combat>(boss_id); auto& boss_combat = world->get<Combat>(boss_id);
auto& boss_ai = world->get<ai::EntityAI>(boss_id); auto& boss_ai = world->get<ai::EntityAI>(boss_id);
combat::BattleEngine battle; combat::BattleEngine battle;
battle.add_enemy({boss_id, &boss_ai, &boss_combat}); battle.add_enemy({boss_id, &boss_ai, &boss_combat});
battle.add_enemy({level.player, &host_ai, &player_combat});
battle.set_all("enemy_found", true); battle.set_all("enemy_found", true);
battle.set_all("in_combat", true); battle.set_all("in_combat", true);
battle.set(boss_id, "tough_personality", true);
battle.set(level.player, "tough_personality", false);
battle.set(level.player, "have_healing", false);
battle.set(level.player, "health_good", player_combat.hp > 20);
battle.player_request("kill_enemy");
battle.plan(); battle.plan();
while(auto act = battle.next()) { while(auto act = battle.next()) {
auto [enemy, ai_action, enemy_action] = *act; auto [enemy, wants_to, cost, host_state] = *act;
Events::Combat result{};
Events::Combat result { switch(host_state) {
player_combat.attack(*enemy.combat), 0 case combat::BattleHostState::agree:
}; result.player_did = player_combat.attack(*enemy.combat);
break;
case combat::BattleHostState::disagree:
fmt::println("HOST DISAGREES! {}", wants_to);
break;
case combat::BattleHostState::not_host:
if(wants_to == "kill_enemy") {
result.enemy_did = enemy.combat->attack(player_combat);
}
}
if(result.player_did > 0) { if(result.player_did > 0) {
auto& the_belt = world->get_the<ritual::Belt>(); auto& the_belt = world->get_the<ritual::Belt>();
dbc::check(the_belt.has(attack_id), "STOP passing invalid attack IDs to the system."); dbc::check(the_belt.has(attack_id), "STOP passing invalid attack IDs to the system.");
} }
if(enemy_action == combat::BattleAction::ATTACK) {
result.enemy_did = enemy.combat->attack(player_combat);
}
// need to replicate this in the boss UI // need to replicate this in the boss UI
world->send<Events::GUI>(Events::GUI::COMBAT, enemy.entity, result); world->send<Events::GUI>(Events::GUI::COMBAT, enemy.entity, result);
} }

View file

@ -72,11 +72,11 @@ namespace boss {
} }
if(result.player_did > 0) { if(result.player_did > 0) {
zoom(boss_is.cell); zoom("boss14", 1.8);
} else if(result.enemy_did > 0) { } else if(result.enemy_did > 0) {
zoom(player_is.cell); zoom(player_is.cell, 2.0);
} else { } else {
zoom(""); zoom("", 0.0);
} }
} }
@ -115,14 +115,14 @@ namespace boss {
$arena.play_animations(); $arena.play_animations();
} }
void UI::zoom(const std::string &cell_name) { void UI::zoom(const std::string &cell_name, double ratio) {
if(cell_name == "") { if(cell_name == "") {
dbc::log("!!!!!!!!! you should add this to guecs"); dbc::log("!!!!!!!!! you should add this to guecs");
$camera.reset($view_texture, BOSS_VIEW_WIDTH, BOSS_VIEW_HEIGHT); $camera.reset($view_texture, BOSS_VIEW_WIDTH, BOSS_VIEW_HEIGHT);
} else { } else {
auto& cell = $arena.$ui.cell_for(cell_name); auto& cell = $arena.$ui.cell_for(cell_name);
$camera.resize(BOSS_VIEW_WIDTH/2, BOSS_VIEW_HEIGHT/2); $camera.resize(double(BOSS_VIEW_WIDTH)/ratio, double(BOSS_VIEW_HEIGHT)/ratio);
$camera.move(float(cell.mid_x), float(cell.mid_y)); $camera.move(float(cell.mid_x), float(cell.mid_y));
$camera.play(); $camera.play();
} }

View file

@ -38,6 +38,6 @@ namespace boss {
void animate_actor(const std::string& actor); void animate_actor(const std::string& actor);
void update_stats(); void update_stats();
void play_animations(); void play_animations();
void zoom(const std::string &cell); void zoom(const std::string &cell, double ratio);
}; };
} }

View file

@ -256,7 +256,7 @@ void System::combat(int attack_id) {
battle.dump(); battle.dump();
while(auto act = battle.next()) { while(auto act = battle.next()) {
auto [enemy, ai_action, enemy_action] = *act; auto [enemy, enemy_action, cost, host_state] = *act;
Events::Combat result { Events::Combat result {
player_combat.attack(*enemy.combat), 0 player_combat.attack(*enemy.combat), 0
@ -266,7 +266,7 @@ void System::combat(int attack_id) {
spawn_attack(world, attack_id, enemy.entity); spawn_attack(world, attack_id, enemy.entity);
} }
if(enemy_action == combat::BattleAction::ATTACK) { if(enemy_action == "kill_enemy") {
result.enemy_did = enemy.combat->attack(player_combat); result.enemy_did = enemy.combat->attack(player_combat);
animation::animate_entity(world, enemy.entity); animation::animate_entity(world, enemy.entity);
} }

View file

@ -18,11 +18,10 @@ TEST_CASE("battle operations fantasy", "[combat-battle]") {
auto host_goal = ai::load_state("Host::final_state"); auto host_goal = ai::load_state("Host::final_state");
BattleEngine battle; BattleEngine battle;
DinkyECS::Entity host = 0; DinkyECS::Entity host = 0;
ai::EntityAI host_ai("Host::actions", host_start, host_goal); ai::EntityAI host_ai("Host::actions", host_start, host_goal);
components::Combat host_combat{100, 100, 20}; components::Combat host_combat{100, 100, 20};
battle.add_enemy({host, &host_ai, &host_combat}); battle.add_enemy({host, &host_ai, &host_combat, true});
DinkyECS::Entity axe_ranger = 1; DinkyECS::Entity axe_ranger = 1;
ai::EntityAI axe_ai("Enemy::actions", ai_start, ai_goal); ai::EntityAI axe_ai("Enemy::actions", ai_start, ai_goal);
@ -40,34 +39,38 @@ TEST_CASE("battle operations fantasy", "[combat-battle]") {
battle.set_all("health_good", true); battle.set_all("health_good", true);
battle.set(rat, "tough_personality", false); battle.set(rat, "tough_personality", false);
battle.set(host, "have_healing", false); battle.set(host, "have_healing", false);
battle.set(host, "health_good", false);
battle.set(host, "tough_personality", false); battle.set(host, "tough_personality", false);
while(host_combat.hp > 0) {
battle.set(host, "health_good", host_combat.hp > 20);
battle.player_request("use_healing");
battle.player_request("kill_enemy");
battle.plan(); battle.plan();
std::set<std::string> requests{
"use_healing",
"kill_enemy"
};
while(auto act = battle.next()) { while(auto act = battle.next()) {
auto& [enemy, wants_to, action] = *act; auto& [enemy, wants_to, cost, host_behavior] = *act;
fmt::println(">>>>> entity: {} wants to {} cost={} and has {} HP and {} damage", fmt::println(">>>>> entity: {} wants to {} cost={} and has {} HP and {} damage",
enemy.entity, wants_to.name, enemy.entity, wants_to,
wants_to.cost, enemy.combat->hp, cost, enemy.combat->hp,
enemy.combat->damage); enemy.combat->damage);
if(enemy.entity == host) { switch(host_behavior) {
// negotiate between the player requested actions and the AI action case BattleHostState::agree:
if(requests.contains(wants_to.name)) {
fmt::println("HOST and PLAYER requests match {}, doing it.", fmt::println("HOST and PLAYER requests match {}, doing it.",
wants_to.name); wants_to);
requests.erase(wants_to.name); break;
} else { case BattleHostState::disagree:
fmt::println("REBELIOUS ACT: {}", wants_to.name); fmt::println("REBELIOUS ACT: {}", wants_to);
battle.$player_requests.clear();
break;
case BattleHostState::not_host:
if(wants_to == "kill_enemy") {
enemy.combat->attack(host_combat);
} }
} }
@ -75,4 +78,5 @@ TEST_CASE("battle operations fantasy", "[combat-battle]") {
} }
REQUIRE(!battle.next()); REQUIRE(!battle.next());
}
} }