diff --git a/src/graph/action/mod.rs b/src/graph/action/mod.rs index 8353bf1..352ed4d 100644 --- a/src/graph/action/mod.rs +++ b/src/graph/action/mod.rs @@ -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 for AttackAction { +//! fn paths() -> Vec> { +//! 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::::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: NodeTrait { + /// Default paths to use with [ActionGraph::from_paths] + fn paths() -> Vec>; +} + +/// Graph that defines relations between physical actions +/// See module docs for usage example +#[derive(Clone, Debug)] +pub struct ActionGraph + where Phys: NodeTrait, Log: Eq { + graph: DiGraphMap, Log>, + state: ActionNode, +} + +impl ActionGraph + where Phys: NodeTrait, Log: Eq { + + /// Constructs new ActionGraph from paths (Log, Phys) + pub fn from_paths(paths: Vec>) -> 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> { + 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 Default for ActionGraph + where Phys: PhysicalAction, 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 + where Phys: NodeTrait { + /// Action type + pub action: Option, + /// Action level + pub level: u8, +} + +impl Default for ActionNode + where Phys: NodeTrait { + fn default() -> Self { + Self { action: None, level: 0 } + } +} + +impl ActionNode + where Phys: NodeTrait { + /// Constructs new node + pub fn new(action: Option, level: u8) -> Self { + Self { action, level } + } +} diff --git a/src/lib.rs b/src/lib.rs index 989fd1b..58c355a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,4 +2,7 @@ //! Combat prototype made with bevy +#[cfg(test)] +mod tests; + pub mod graph; diff --git a/src/tests/graph.rs b/src/tests/graph.rs new file mode 100644 index 0000000..6b2a641 --- /dev/null +++ b/src/tests/graph.rs @@ -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 for AttackAction { + fn paths() -> Vec> { + 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> { + 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::::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::::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()); + } + } + } +} diff --git a/src/tests/mod.rs b/src/tests/mod.rs new file mode 100644 index 0000000..88f0f58 --- /dev/null +++ b/src/tests/mod.rs @@ -0,0 +1 @@ +mod graph;