Skip to main content

tactical_rpg/
tactical_rpg.rs

1#![allow(dead_code)]
2#![allow(clippy::needless_return)]
3#![allow(clippy::implicit_return)]
4#![allow(clippy::uninlined_format_args)]
5#![allow(clippy::items_after_statements)]
6#![allow(clippy::unnecessary_cast)]
7#![allow(clippy::doc_markdown)]
8#![allow(clippy::cast_sign_loss)]
9#![allow(clippy::explicit_iter_loop)]
10#![allow(clippy::format_in_format_args)]
11#![allow(clippy::cast_precision_loss)]
12#![allow(clippy::wildcard_imports)]
13#![allow(clippy::too_many_lines)]
14#![allow(clippy::std_instead_of_core)]
15#![allow(clippy::similar_names)]
16#![allow(clippy::duplicated_attributes)]
17#![allow(clippy::cast_possible_truncation)]
18#![allow(clippy::trivially_copy_pass_by_ref)]
19#![allow(clippy::missing_inline_in_public_items)]
20#![allow(clippy::useless_vec)]
21#![allow(clippy::unnested_or_patterns)]
22#![allow(clippy::else_if_without_else)]
23#![allow(clippy::unreadable_literal)]
24#![allow(clippy::redundant_else)]
25#![allow(clippy::cast_lossless)]
26#![allow(clippy::map_unwrap_or)]
27#![allow(clippy::unused_self)]
28#![allow(clippy::min_ident_chars)]
29#![allow(clippy::std_instead_of_alloc)]
30#![allow(clippy::struct_field_names)]
31//! Tactical RPG example demonstrating advanced ECS gameplay mechanics.
32//!
33//! This example showcases a turn-based tactical RPG combat system using
34//! the tiles_tools ECS framework. Features include:
35//!
36//! - Turn-based combat with initiative system
37//! - Movement and attack ranges on hexagonal grid
38//! - AI-controlled enemies with different behaviors  
39//! - Player-controlled units with tactical decisions
40//! - Line-of-sight and area-of-effect attacks
41//! - Experience and leveling system
42//! - Equipment and inventory management
43//!
44//! Run with: `cargo run --example tactical_rpg --features enabled`
45
46use tiles_tools::{
47  ecs::{World, Position, Health, Stats, Team, AI, Movable, Size},
48  coordinates::{
49  hexagonal::{Coordinate as HexCoord, Axial, Pointy},
50  },
51  pathfind::astar,
52};
53use std::collections::VecDeque;
54
55// =============================================================================
56// Game-Specific Components
57// =============================================================================
58
59/// Experience and leveling component
60#[derive(Debug, Clone, Copy)]
61struct Experience {
62  current_xp: u32,
63  level: u32,
64  xp_to_next_level: u32,
65}
66
67impl Experience {
68  pub fn new(level: u32) -> Self {
69  Self {
70    current_xp: 0,
71    level,
72    xp_to_next_level: Self::xp_required_for_level(level + 1),
73  }
74  }
75  
76  pub fn add_xp(&mut self, xp: u32) -> bool {
77  self.current_xp += xp;
78  if self.current_xp >= self.xp_to_next_level {
79    self.level_up();
80    true
81  } else {
82    false
83  }
84  }
85  
86  fn level_up(&mut self) {
87  self.level += 1;
88  self.current_xp -= self.xp_to_next_level;
89  self.xp_to_next_level = Self::xp_required_for_level(self.level + 1);
90  }
91  
92  fn xp_required_for_level(level: u32) -> u32 {
93  level * level * 100
94  }
95}
96
97/// Initiative component for turn order
98#[derive(Debug, Clone, Copy)]
99struct Initiative {
100  base_initiative: u32,
101  current_initiative: u32,
102  has_acted: bool,
103}
104
105impl Initiative {
106  pub fn new(base: u32) -> Self {
107  Self {
108    base_initiative: base,
109    current_initiative: base,
110    has_acted: false,
111  }
112  }
113  
114  pub fn reset_turn(&mut self) {
115  self.current_initiative = self.base_initiative;
116  self.has_acted = false;
117  }
118  
119  pub fn act(&mut self) {
120  self.has_acted = true;
121  }
122}
123
124/// Equipment and inventory component
125#[derive(Debug, Clone)]
126struct Equipment {
127  weapon: Option<Weapon>,
128  armor: Option<Armor>,
129  accessories: Vec<Accessory>,
130  inventory_slots: u32,
131}
132
133#[derive(Debug, Clone)]
134struct Weapon {
135  name: String,
136  attack_bonus: u32,
137  range: u32,
138  damage_type: DamageType,
139}
140
141#[derive(Debug, Clone)]
142struct Armor {
143  name: String,
144  defense_bonus: u32,
145  resistances: Vec<DamageType>,
146}
147
148#[derive(Debug, Clone)]
149struct Accessory {
150  name: String,
151  effect: AccessoryEffect,
152}
153
154#[derive(Debug, Clone, Copy, PartialEq)]
155enum DamageType {
156  Physical,
157  Fire,
158  Ice,
159  Lightning,
160  Healing,
161}
162
163#[derive(Debug, Clone)]
164enum AccessoryEffect {
165  StatBonus(StatType, u32),
166  Resistance(DamageType),
167  ExtraMovement(u32),
168}
169
170#[derive(Debug, Clone, Copy)]
171enum StatType {
172  Attack,
173  Defense,
174  Speed,
175}
176
177/// Ability component for special attacks and spells
178#[derive(Debug, Clone)]
179struct Abilities {
180  abilities: Vec<Ability>,
181  mana: u32,
182  max_mana: u32,
183}
184
185#[derive(Debug, Clone)]
186struct Ability {
187  name: String,
188  mana_cost: u32,
189  range: u32,
190  area_of_effect: u32,
191  damage: u32,
192  damage_type: DamageType,
193  cooldown: u32,
194  current_cooldown: u32,
195}
196
197impl Abilities {
198  pub fn new(max_mana: u32) -> Self {
199  Self {
200    abilities: Vec::new(),
201    mana: max_mana,
202    max_mana,
203  }
204  }
205  
206  pub fn add_ability(&mut self, ability: Ability) {
207  self.abilities.push(ability);
208  }
209  
210  pub fn can_use_ability(&self, ability_index: usize) -> bool {
211  if let Some(ability) = self.abilities.get(ability_index) {
212    self.mana >= ability.mana_cost && ability.current_cooldown == 0
213  } else {
214    false
215  }
216  }
217  
218  pub fn use_ability(&mut self, ability_index: usize) -> Option<&Ability> {
219  if self.can_use_ability(ability_index) {
220    let ability = &mut self.abilities[ability_index];
221    self.mana -= ability.mana_cost;
222    ability.current_cooldown = ability.cooldown;
223    Some(&*ability)
224  } else {
225    None
226  }
227  }
228}
229
230// =============================================================================
231// Game State Management
232// =============================================================================
233
234/// Main tactical RPG game state
235struct TacticalRPG {
236  world: World,
237  turn_queue: VecDeque<hecs::Entity>,
238  current_turn: Option<hecs::Entity>,
239  turn_number: u32,
240  player_team: Team,
241  enemy_team: Team,
242  game_phase: GamePhase,
243}
244
245#[derive(Debug, Clone, Copy, PartialEq)]
246enum GamePhase {
247  Planning,    // Player selects actions
248  Execution,   // Actions are executed
249  AI,          // AI makes decisions
250  Resolution,  // Effects are resolved
251}
252
253impl TacticalRPG {
254  /// Creates a new tactical RPG game
255  pub fn new() -> Self {
256  let mut world = World::new();
257  let player_team = Team::new(0);
258  let enemy_team = Team::hostile(1);
259  
260  // Spawn player units
261  let player_warrior = world.spawn((
262    Position::new(HexCoord::<Axial, Pointy>::new(-2, 1)),
263    Health::new(120),
264    Stats::new(18, 12, 10, 1),
265    player_team,
266    Movable::new(3),
267    Experience::new(1),
268    Initiative::new(15),
269    Equipment {
270      weapon: Some(Weapon {
271        name: "Iron Sword".to_string(),
272        attack_bonus: 5,
273        range: 1,
274        damage_type: DamageType::Physical,
275      }),
276      armor: Some(Armor {
277        name: "Chain Mail".to_string(),
278        defense_bonus: 3,
279        resistances: vec![DamageType::Physical],
280      }),
281      accessories: Vec::new(),
282      inventory_slots: 10,
283    },
284    Size::single(),
285  ));
286  
287  let player_mage = world.spawn((
288    Position::new(HexCoord::<Axial, Pointy>::new(-1, 0)),
289    Health::new(80),
290    Stats::new(12, 8, 14, 1),
291    player_team,
292    Movable::new(2),
293    Experience::new(1),
294    Initiative::new(12),
295    Equipment {
296      weapon: Some(Weapon {
297        name: "Fire Staff".to_string(),
298        attack_bonus: 2,
299        range: 3,
300        damage_type: DamageType::Fire,
301      }),
302      armor: None,
303      accessories: vec![Accessory {
304        name: "Mana Crystal".to_string(),
305        effect: AccessoryEffect::StatBonus(StatType::Speed, 2),
306      }],
307      inventory_slots: 8,
308    },
309    {
310      let mut abilities = Abilities::new(50);
311      abilities.add_ability(Ability {
312        name: "Fireball".to_string(),
313        mana_cost: 10,
314        range: 4,
315        area_of_effect: 1,
316        damage: 25,
317        damage_type: DamageType::Fire,
318        cooldown: 2,
319        current_cooldown: 0,
320      });
321      abilities.add_ability(Ability {
322        name: "Heal".to_string(),
323        mana_cost: 8,
324        range: 2,
325        area_of_effect: 0,
326        damage: 0, // Actually healing
327        damage_type: DamageType::Healing,
328        cooldown: 1,
329        current_cooldown: 0,
330      });
331      abilities
332    },
333    Size::single(),
334  ));
335  
336  // Spawn enemy units
337  let enemy_goblin = world.spawn((
338    Position::new(HexCoord::<Axial, Pointy>::new(2, -1)),
339    Health::new(60),
340    Stats::new(12, 6, 12, 1),
341    enemy_team,
342    Movable::new(4),
343    AI::new(1.0),
344    Initiative::new(14),
345    Equipment {
346      weapon: Some(Weapon {
347        name: "Rusty Dagger".to_string(),
348        attack_bonus: 2,
349        range: 1,
350        damage_type: DamageType::Physical,
351      }),
352      armor: None,
353      accessories: Vec::new(),
354      inventory_slots: 5,
355    },
356    Size::single(),
357  ));
358  
359  let enemy_orc = world.spawn((
360    Position::new(HexCoord::<Axial, Pointy>::new(3, -2)),
361    Health::new(100),
362    Stats::new(16, 10, 8, 1),
363    enemy_team,
364    Movable::new(2),
365    AI::new(1.5),
366    Initiative::new(10),
367    Equipment {
368      weapon: Some(Weapon {
369        name: "War Axe".to_string(),
370        attack_bonus: 6,
371        range: 1,
372        damage_type: DamageType::Physical,
373      }),
374      armor: Some(Armor {
375        name: "Hide Armor".to_string(),
376        defense_bonus: 2,
377        resistances: Vec::new(),
378      }),
379      accessories: Vec::new(),
380      inventory_slots: 6,
381    },
382    Size::single(),
383  ));
384  
385  let mut turn_queue = VecDeque::new();
386  turn_queue.extend([player_warrior, player_mage, enemy_goblin, enemy_orc]);
387  
388  Self {
389    world,
390    turn_queue,
391    current_turn: None,
392    turn_number: 1,
393    player_team,
394    enemy_team,
395    game_phase: GamePhase::Planning,
396  }
397  }
398  
399  /// Starts a new turn
400  pub fn start_turn(&mut self) {
401  if let Some(entity) = self.turn_queue.pop_front() {
402    self.current_turn = Some(entity);
403    println!("\n=== Turn {} ===", self.turn_number);
404    self.print_unit_status(entity);
405    
406    // Check if this is a player or AI unit
407    let team_id = {
408      if let Ok(team) = self.world.get::<Team>(entity) {
409        team.id
410      } else {
411        return;
412      }
413    };
414    
415    if team_id == self.player_team.id {
416      self.game_phase = GamePhase::Planning;
417      self.handle_player_turn(entity);
418    } else {
419      self.game_phase = GamePhase::AI;
420      self.handle_ai_turn(entity);
421    }
422  } else {
423    // End of round, reset turn queue
424    self.reset_turn_queue();
425    self.turn_number += 1;
426  }
427  }
428  
429  /// Handles a player unit's turn
430  fn handle_player_turn(&mut self, entity: hecs::Entity) {
431  println!("šŸŽ® Player turn - planning actions...");
432  
433  // In a real implementation, this would wait for player input
434  // For demo purposes, we'll simulate some actions
435  
436  let (pos_coord, target) = {
437    if let Ok(pos) = self.world.get::<Position<HexCoord<Axial, Pointy>>>(entity) {
438      let pos_coord = pos.coord;
439      println!("Player unit at ({}, {})", pos_coord.q, pos_coord.r);
440      
441      // Find nearest enemy
442      let target = self.find_nearest_enemy(entity);
443      (pos_coord, target)
444    } else {
445      return;
446    }
447  };
448  
449  if let Some(target) = target {
450    let pos = Position::new(pos_coord);
451    println!("Targeting enemy at distance {}", pos.distance_to(&target.1));
452    
453    // Try to attack or move closer
454    if pos.distance_to(&target.1) <= 2 {
455      self.execute_attack(entity, target.0);
456    } else {
457      self.execute_move_toward(entity, &target.1.coord);
458    }
459  }
460  
461  self.game_phase = GamePhase::Resolution;
462  }
463  
464  /// Handles an AI unit's turn
465  fn handle_ai_turn(&mut self, entity: hecs::Entity) {
466  println!("šŸ¤– AI turn - calculating optimal action...");
467  
468  let (pos_coord, target) = {
469    if let Ok(pos) = self.world.get::<Position<HexCoord<Axial, Pointy>>>(entity) {
470      let pos_coord = pos.coord;
471      println!("AI unit at ({}, {})", pos_coord.q, pos_coord.r);
472      
473      // Simple AI: move toward nearest player unit and attack if possible
474      let target = self.find_nearest_player(entity);
475      (pos_coord, target)
476    } else {
477      return;
478    }
479  };
480  
481  if let Some(target) = target {
482    let pos = Position::new(pos_coord);
483    let distance = pos.distance_to(&target.1);
484    println!("AI targeting player at distance {}", distance);
485    
486    if distance <= 1 {
487      // Attack if adjacent
488      self.execute_attack(entity, target.0);
489    } else if distance <= 4 {
490      // Move closer if within reasonable range
491      self.execute_move_toward(entity, &target.1.coord);
492    } else {
493      // Hold position if target too far
494      println!("AI unit holding position");
495    }
496  }
497  
498  self.game_phase = GamePhase::Resolution;
499  }
500  
501  /// Executes an attack between two units
502  fn execute_attack(&mut self, attacker: hecs::Entity, target: hecs::Entity) {
503  let (final_damage, target_level) = {
504    let attacker_stats = self.world.get::<Stats>(attacker).expect("attacker should have stats");
505    let attacker_equipment = self.world.get::<Equipment>(attacker).expect("attacker should have equipment");
506    let target_stats = self.world.get::<Stats>(target).expect("target should have stats");
507    
508    let mut base_damage = attacker_stats.attack;
509    if let Some(weapon) = &attacker_equipment.weapon {
510      base_damage += weapon.attack_bonus;
511    }
512    
513    let final_damage = base_damage.saturating_sub(target_stats.defense / 2).max(1);
514    (final_damage, target_stats.level)
515  };
516  
517  // Apply damage
518  let target_defeated = {
519    if let Ok(mut target_health) = self.world.get_mut::<Health>(target) {
520      let old_health = target_health.current;
521      target_health.damage(final_damage);
522      
523      println!("šŸ’„ Attack! {} damage dealt ({} -> {} HP)", 
524               final_damage, old_health, target_health.current);
525      
526      !target_health.is_alive()
527    } else {
528      false
529    }
530  };
531  
532  if target_defeated {
533    println!("šŸ’€ Unit defeated!");
534    
535    // Award experience to attacker
536    if let Ok(mut exp) = self.world.get_mut::<Experience>(attacker) {
537      let xp_gained = target_level * 50;
538      if exp.add_xp(xp_gained) {
539        println!("šŸŽ‰ Level up! Now level {}", exp.level);
540      }
541    }
542  }
543  }
544  
545  /// Executes movement toward a target position
546  fn execute_move_toward(&mut self, entity: hecs::Entity, target: &HexCoord<Axial, Pointy>) {
547  if let Ok(pos) = self.world.get::<Position<HexCoord<Axial, Pointy>>>(entity) {
548    if let Ok(movable) = self.world.get::<Movable>(entity) {
549      // Use pathfinding to find route
550      let path_result = astar(
551        &pos.coord,
552        target,
553        |coord| self.is_position_passable(coord),
554        |_| 1,
555      );
556      
557      if let Some((path, _cost)) = path_result {
558        let move_distance = movable.range.min(path.len() as u32 - 1);
559        if move_distance > 0 {
560          let new_pos = path[move_distance as usize];
561          
562          // Update position (in real implementation would use proper ECS mutation)
563          println!("🚶 Moving from ({}, {}) to ({}, {})", 
564                   pos.coord.q, pos.coord.r, new_pos.q, new_pos.r);
565        }
566      }
567    }
568  }
569  }
570  
571  /// Finds the nearest enemy unit
572  fn find_nearest_enemy(&self, entity: hecs::Entity) -> Option<(hecs::Entity, Position<HexCoord<Axial, Pointy>>)> {
573  if let Ok(our_team) = self.world.get::<Team>(entity) {
574    if let Ok(our_pos) = self.world.get::<Position<HexCoord<Axial, Pointy>>>(entity) {
575      return self.world.find_nearest_entity(&our_pos)
576        .and_then(|(nearest_entity, nearest_pos, _distance)| {
577          if let Ok(their_team) = self.world.get::<Team>(nearest_entity) {
578            if our_team.is_hostile_to(&their_team) {
579              Some((nearest_entity, nearest_pos))
580            } else {
581              None
582            }
583          } else {
584            None
585          }
586        });
587    }
588  }
589  None
590  }
591  
592  /// Finds the nearest player unit
593  fn find_nearest_player(&self, entity: hecs::Entity) -> Option<(hecs::Entity, Position<HexCoord<Axial, Pointy>>)> {
594  if let Ok(our_pos) = self.world.get::<Position<HexCoord<Axial, Pointy>>>(entity) {
595    return self.world.find_nearest_entity(&our_pos)
596      .and_then(|(nearest_entity, nearest_pos, _distance)| {
597        if let Ok(their_team) = self.world.get::<Team>(nearest_entity) {
598          if their_team.id == self.player_team.id {
599            Some((nearest_entity, nearest_pos))
600          } else {
601            None
602          }
603        } else {
604          None
605        }
606      });
607  }
608  None
609  }
610  
611  /// Checks if a position is passable (no other units)
612  fn is_position_passable(&self, _coord: &HexCoord<Axial, Pointy>) -> bool {
613  // In a real implementation, would check for other units and obstacles
614  true
615  }
616  
617  /// Resets the turn queue for a new round
618  fn reset_turn_queue(&mut self) {
619  // Collect all living units sorted by initiative
620  let mut units_by_initiative = Vec::new();
621  
622  for (entity, (init, health)) in self.world.query::<(&Initiative, &Health)>().iter() {
623    if health.is_alive() {
624      units_by_initiative.push((entity, init.current_initiative));
625    }
626  }
627  
628  units_by_initiative.sort_by(|a, b| b.1.cmp(&a.1)); // Descending initiative
629  
630  self.turn_queue.clear();
631  for (entity, _init) in units_by_initiative {
632    self.turn_queue.push_back(entity);
633  }
634  }
635  
636  /// Prints the status of a unit
637  fn print_unit_status(&self, entity: hecs::Entity) {
638  if let Ok(health) = self.world.get::<Health>(entity) {
639    if let Ok(stats) = self.world.get::<Stats>(entity) {
640      if let Ok(pos) = self.world.get::<Position<HexCoord<Axial, Pointy>>>(entity) {
641        if let Ok(team) = self.world.get::<Team>(entity) {
642          let team_name = if team.id == self.player_team.id { "Player" } else { "Enemy" };
643          
644          println!("{} Unit at ({}, {}): {}/{} HP, Level {} (ATK:{} DEF:{} SPD:{})", 
645                   team_name,
646                   pos.coord.q, pos.coord.r,
647                   health.current, health.maximum,
648                   stats.level, stats.attack, stats.defense, stats.speed);
649          
650          if let Ok(equipment) = self.world.get::<Equipment>(entity) {
651            if let Some(weapon) = &equipment.weapon {
652              println!("  šŸ“‹ Equipped: {} (+{} attack)", weapon.name, weapon.attack_bonus);
653            }
654          }
655        }
656      }
657    }
658  }
659  }
660  
661  /// Prints the current battlefield state
662  pub fn print_battlefield(&self) {
663  println!("\nšŸ“ Battlefield Status:");
664  
665  // Find all living units
666  let mut units = Vec::new();
667  for (_entity, (pos, health, team)) in self.world.query::<(&Position<HexCoord<Axial, Pointy>>, &Health, &Team)>().iter() {
668    if health.is_alive() {
669      let symbol = if team.id == self.player_team.id { "🟢" } else { "šŸ”“" };
670      units.push((pos.coord.q, pos.coord.r, symbol));
671    }
672  }
673  
674  if units.is_empty() {
675    println!("Battle concluded!");
676    return;
677  }
678  
679  // Find bounds
680  let min_q = units.iter().map(|(q, _, _)| *q).min().unwrap_or(0) - 1;
681  let max_q = units.iter().map(|(q, _, _)| *q).max().unwrap_or(0) + 1;
682  let min_r = units.iter().map(|(_, r, _)| *r).min().unwrap_or(0) - 1;
683  let max_r = units.iter().map(|(_, r, _)| *r).max().unwrap_or(0) + 1;
684  
685  // Print hexagonal grid representation
686  for r in min_r..=max_r {
687    // Add offset for hexagonal display
688    if r % 2 == 1 {
689      print!(" ");
690    }
691    
692    for q in min_q..=max_q {
693      let symbol = units.iter()
694        .find(|(unit_q, unit_r, _)| *unit_q == q && *unit_r == r)
695        .map(|(_, _, symbol)| *symbol)
696        .unwrap_or("⬔");
697      print!("{} ", symbol);
698    }
699    println!();
700  }
701  }
702  
703  /// Runs the complete game simulation
704  pub fn run_simulation(&mut self) {
705  println!("šŸŽÆ Tactical RPG Combat Simulation");
706  println!("=================================");
707  println!("🟢 = Player Units");
708  println!("šŸ”“ = Enemy Units");
709  println!("⬔ = Empty Hex");
710  
711  self.print_battlefield();
712  
713  // Run several turns
714  for turn in 1..=10 {
715    self.start_turn();
716    self.print_battlefield();
717    
718    // Check victory conditions
719    let player_units_alive = self.count_living_units(self.player_team.id);
720    let enemy_units_alive = self.count_living_units(self.enemy_team.id);
721    
722    if player_units_alive == 0 {
723      println!("šŸ’€ Defeat! All player units have fallen.");
724      break;
725    } else if enemy_units_alive == 0 {
726      println!("šŸ† Victory! All enemies defeated.");
727      break;
728    }
729    
730    if turn >= 10 {
731      println!("ā° Battle continues...");
732      break;
733    }
734    
735    std::thread::sleep(std::time::Duration::from_millis(1500));
736  }
737  }
738  
739  /// Counts living units for a team
740  fn count_living_units(&self, team_id: u32) -> usize {
741  let mut count = 0;
742  for (_entity, (health, team)) in self.world.query::<(&Health, &Team)>().iter() {
743    if health.is_alive() && team.id == team_id {
744      count += 1;
745    }
746  }
747  count
748  }
749}
750
751/// Main entry point for the tactical RPG demo
752fn main() {
753  let mut game = TacticalRPG::new();
754  game.run_simulation();
755  
756  println!("\n✨ Tactical RPG Demo Complete!");
757  println!("This example showcases:");
758  println!("• Turn-based combat with initiative system");
759  println!("• AI decision-making and pathfinding"); 
760  println!("• Equipment and stat systems");
761  println!("• Experience and leveling mechanics");
762  println!("• Grid-aware tactical positioning");
763}