feat: Implemented ActionGraph

This commit is contained in:
Alexey 2026-04-09 17:05:21 +03:00
commit 62199a1817
4 changed files with 262 additions and 3 deletions

View file

@ -1,3 +1,190 @@
//! Action graph is a directed [GraphMap] defined with physical actions (vertices) and logical actions (edges)
//! Logical actions represent action types sent to character
//! Physical actions are actions that are performed with the character
//! [ActionGraph] is a specialized directed [GraphMap],
//! defined with physical actions (vertices) and logical actions (edges),
//! named phys and logs respectively
//!
//! # Physical actions (Phys)
//! Phys are actions that are performed with the character
//! Graph wraps phys with [ActionNode] that also contains node level in the graph
//! Phys "level" is the amount of logs taken to reach this state
//! and allows several copies of each phys to be placed in the graph
//!
//! # Logical actions (Log)
//! Logs represent action types sent to character as input
//! They define which phys should be performed next
//!
//! # Example
//! ```
//! use bevy_combat_proto::graph::action::{ActionGraph, ActionNode, PhysicalAction};
//!
//! // This is the logical action
//! ##[derive(Debug, PartialEq, Eq)]
//! enum AttackType {
//! Light,
//! Heavy,
//! }
//! // This is the physical action
//! ##[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
//! enum AttackAction {
//! Stab,
//! VerticalSlash,
//! HorizontalSlash,
//! }
//!
//! // While not necessary, it's useful to implement PhysicalAction,
//! // because it provides Default for ActionGraph
//! impl PhysicalAction<AttackType> for AttackAction {
//! fn paths() -> Vec<Vec<(AttackType, Self)>> {
//! use AttackType as L;
//! use AttackAction as P;
//! vec![
//! vec![(L::Heavy, P::VerticalSlash), (L::Light, P::HorizontalSlash), (L::Heavy, P::Stab)],
//! vec![(L::Heavy, P::VerticalSlash), (L::Heavy, P::Stab), (L::Light, P::Stab)],
//! vec![(L::Light, P::HorizontalSlash), (L::Heavy, P::Stab), (L::Light, P::Stab)],
//! vec![(L::Light, P::HorizontalSlash), (L::Light, P::VerticalSlash), (L::Heavy, P::HorizontalSlash)],
//! vec![(L::Light, P::HorizontalSlash), (L::Light, P::VerticalSlash), (L::Light, P::VerticalSlash)],
//! ]
//! }
//! }
//!
//! // Attack combos in this case may be implemented with such graph:
//! // -(Light)------------------> None
//! // /
//! // -(Light)-> Some(HorizontalSlash)-(Heavy)------------> Some(Stab)
//! // / /
//! // -(Heavy)---> Some(VerticalSlash) -(Light)--
//! // / \ /
//! // None -(Heavy)------------> Some(Stab)-(Heavy)------------------> None
//! // \ /
//! // -(Light)-> Some(HorizontalSlash) -(Heavy)-> Some(HorizontalSlash)
//! // \ /
//! // -(Light)---> Some(VerticalSlash)-(Light)---> Some(VerticalSlash)
//! // [0 logs] [1 log] [2 logs] [3 logs]
//!
//! // Given that AttackType::Light is represented with button A,
//! // and AttackType::Heavy is represented with button B, these are the combos:
//! // B->A->B == VerticalSlash->HorizontalSlash->Stab
//! // B->B->A == VerticalSlash->Stab->Stab
//! // A->B->A == HorizontalSlash->Stab->Stab]
//! // A->A->B == HorizontalSlash->VerticalSlash->HorizontalSlash]
//! // A->A->A == HorizontalSlash->VerticalSlash->VerticalSlash]
//!
//! fn main() {
//! let mut graph = ActionGraph::<AttackAction, AttackType>::default();
//!
//! // Perform some combo
//!
//! // Gives VerticalSlash with level 1
//! let first_action = graph.next(AttackType::Heavy);
//! // Gives Stab with level 2
//! let second_action = graph.next(AttackType::Heavy);
//! // Gives Stab with level 3
//! let third_action = graph.next(AttackType::Light);
//! // Gives None, which can be translated to starting point of the graph using
//! // unwrap_or_default
//! let fourth_action = graph.next(AttackType::Light);
//!
//! assert_eq!(first_action, Some(ActionNode::new(Some(AttackAction::VerticalSlash), 1)));
//! assert_eq!(second_action, Some(ActionNode::new(Some(AttackAction::Stab), 2)));
//! assert_eq!(third_action, Some(ActionNode::new(Some(AttackAction::Stab), 3)));
//! assert_eq!(fourth_action, None);
//! }
//! ```
use std::{fmt::Debug, hash::Hash};
use bevy::prelude::*;
use petgraph::{graphmap::NodeTrait, prelude::*};
/// This trait is used to implement [Default] on specialized [ActionGraph]
pub trait PhysicalAction<Log: Eq>: NodeTrait {
/// Default paths to use with [ActionGraph::from_paths]
fn paths() -> Vec<Vec<(Log, Self)>>;
}
/// Graph that defines relations between physical actions
/// See module docs for usage example
#[derive(Clone, Debug)]
pub struct ActionGraph<Phys, Log>
where Phys: NodeTrait, Log: Eq {
graph: DiGraphMap<ActionNode<Phys>, Log>,
state: ActionNode<Phys>,
}
impl<Phys, Log> ActionGraph<Phys, Log>
where Phys: NodeTrait, Log: Eq {
/// Constructs new ActionGraph from paths (Log, Phys)
pub fn from_paths(paths: Vec<Vec<(Log, Phys)>>) -> Self {
let mut g = DiGraphMap::new();
let start_node = ActionNode::default();
g.add_node(start_node);
for path in paths {
let mut current_node = start_node;
for (i, (log, phys)) in path.into_iter().enumerate() {
let next_node = ActionNode::new(Some(phys), 1 + i as u8);
if g.nodes().find(|n| *n == next_node).is_none() {
g.add_node(next_node);
}
g.add_edge(current_node, next_node, log);
current_node = next_node;
}
}
Self { graph: g, state: start_node }
}
/// Takes log and returns next phys, returns [None] if graph returned to starting point
/// Use [Option::unwrap_or_default] on result of this method
pub fn next(&mut self, log: Log) -> Option<ActionNode<Phys>> {
let state = self.state;
let out_state = self.graph.edges(state)
.find_map(|(_, next_phys, checked_log)| {
if checked_log == &log && next_phys.action.is_some() { Some(next_phys) } else { None }
});
let next_state = out_state.unwrap_or_default();
self.state = next_state;
out_state
}
/// Resets state of the graph
pub fn reset(&mut self) {
self.state = ActionNode::default();
}
}
impl<Phys, Log> Default for ActionGraph<Phys, Log>
where Phys: PhysicalAction<Log>, Log: Eq {
fn default() -> Self {
Self::from_paths(Phys::paths())
}
}
/// Node type used in [ActionGraph], wraps physical action with it's level in the graph
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Reflect)]
#[reflect(Clone, PartialEq, Hash)]
pub struct ActionNode<Phys>
where Phys: NodeTrait {
/// Action type
pub action: Option<Phys>,
/// Action level
pub level: u8,
}
impl<Phys> Default for ActionNode<Phys>
where Phys: NodeTrait {
fn default() -> Self {
Self { action: None, level: 0 }
}
}
impl<Phys> ActionNode<Phys>
where Phys: NodeTrait {
/// Constructs new node
pub fn new(action: Option<Phys>, level: u8) -> Self {
Self { action, level }
}
}

View file

@ -2,4 +2,7 @@
//! Combat prototype made with bevy
#[cfg(test)]
mod tests;
pub mod graph;

68
src/tests/graph.rs Normal file
View file

@ -0,0 +1,68 @@
use crate::graph::action::{ActionGraph, ActionNode, PhysicalAction};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AttackType {
Light,
Heavy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
enum AttackAction {
Stab,
VerticalSlash,
HorizontalSlash,
}
impl PhysicalAction<AttackType> for AttackAction {
fn paths() -> Vec<Vec<(AttackType, Self)>> {
use AttackType as L;
use AttackAction as P;
vec![
vec![(L::Heavy, P::VerticalSlash), (L::Light, P::HorizontalSlash), (L::Heavy, P::Stab)],
vec![(L::Heavy, P::VerticalSlash), (L::Heavy, P::Stab), (L::Light, P::Stab)],
vec![(L::Light, P::HorizontalSlash), (L::Heavy, P::Stab), (L::Light, P::Stab)],
vec![(L::Light, P::HorizontalSlash), (L::Light, P::VerticalSlash), (L::Heavy, P::HorizontalSlash)],
vec![(L::Light, P::HorizontalSlash), (L::Light, P::VerticalSlash), (L::Light, P::VerticalSlash)],
]
}
}
fn get_wrong_combos() -> Vec<Vec<AttackType>> {
use AttackType as L;
vec![
vec![L::Heavy, L::Light, L::Light],
vec![L::Heavy, L::Heavy, L::Heavy],
vec![L::Light, L::Heavy, L::Heavy],
]
}
#[test]
fn action_graph_combos() {
let mut graph = ActionGraph::<AttackAction, AttackType>::default();
for combo in AttackAction::paths() {
for (i, (log, phys)) in combo.into_iter().enumerate() {
let expected_node = ActionNode::new(Some(phys), 1 + i as u8);
let checked_node = graph.next(log).unwrap();
assert_eq!(checked_node, expected_node);
}
graph.reset();
}
}
#[test]
fn test_wrong_combos() {
let wrong_combos = get_wrong_combos();
let mut graph = ActionGraph::<AttackAction, AttackType>::default();
for combo in wrong_combos {
for (i, log) in combo.iter().enumerate() {
if i < combo.len() - 1 {
assert!(graph.next(*log).is_some());
} else {
assert!(graph.next(*log).is_none());
}
}
}
}

1
src/tests/mod.rs Normal file
View file

@ -0,0 +1 @@
mod graph;