From 299d8f8573371c49a7d48d00fd1c1b698e1ba20d Mon Sep 17 00:00:00 2001 From: Luis Camargo Date: Fri, 13 Feb 2026 20:07:41 -0800 Subject: [PATCH 1/3] feat: add Battle Simulator screen (screen 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New BattleSimulator screen accessible via Tab or pressing 5 - Pick 2 Pokémon from team (starred) or full Pokédex to battle - Gen 1 damage mechanics: type effectiveness, STAB, base stats - Turn-by-turn battle with HP bar animations - Move selection with type-colored display - Battle log with effectiveness messages (super effective, etc.) - AI opponent picks moves based on type advantage (70%) or randomly (30%) - Speed-based turn order (Gen 1 style) - Default moves generated from Pokémon types when none assigned - Full type effectiveness chart (18 types) - Added rand dependency for damage variance and AI - All 63 tests passing --- .DS_Store | Bin 0 -> 6148 bytes Cargo.lock | 63 +++++- Cargo.toml | 1 + src/app.rs | 429 ++++++++++++++++++++++++++++++++++++- src/models/battle.rs | 359 +++++++++++++++++++++++++++++++ src/models/mod.rs | 1 + src/ui/battle_simulator.rs | 301 ++++++++++++++++++++++++++ src/ui/mod.rs | 2 + 8 files changed, 1150 insertions(+), 6 deletions(-) create mode 100644 .DS_Store create mode 100644 src/models/battle.rs create mode 100644 src/ui/battle_simulator.rs diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..f98da7fbf5236a0d362da5c2165df024bba1055c GIT binary patch literal 6148 zcmeHK!A{#i5S>i|*hNS=Pzm*9l`BdUKuD+;*Mvh4z2J%-+A6i<2rP^j#ZGgGBKZU8 zg-_ww59#l;Z+16Gap(!Ds?d%!`(|h7*}HEOuT4ZEm?S--HW4{+#)gaL5#w?86>FHx zI#8%K_!Z?d!~yWL-+(QK`) zZ#Wyyrt>wtl+&<`%1IGKU(IH{!=vt=*V{innD2SB<9&1v502(@*Lk`7=IzP3}BtAJI&D)2M~%u~hLeA;C!+bUocc!mn_^TC5N z`UWeF>d}Fwfi91?7}*f>>ms9P&^K6V#0X4iDo|5}`NR;Kj=X2;e1nxnO(&u5jO&;? z3-g5{)ZLNyR5=M>qiwAMR)J*&n!4NO^Z)ek-~Y=b+p`K-1)h}xqSX)k0hVOW)|JKa vS?j^S!P&U3()d6@ppIhH@=?44SBAXD0nj&CX+#EQUj&p4wy_HQQw9D2__=!o literal 0 HcmV?d00001 diff --git a/Cargo.lock b/Cargo.lock index 11c938d..a3cba97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1366,7 +1366,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.5", ] [[package]] @@ -1430,6 +1430,7 @@ dependencies = [ "crossterm 0.28.1", "dirs", "image", + "rand 0.9.2", "ratatui", "reqwest", "serde", @@ -1460,6 +1461,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1499,7 +1509,27 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1508,6 +1538,15 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "ratatui" version = "0.30.0" @@ -2891,6 +2930,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "zerofrom" version = "0.1.6" diff --git a/Cargo.toml b/Cargo.toml index f4fc9bf..66d1647 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ serde_json = "1" image = { version = "0.25", default-features = false, features = ["png"] } anyhow = "1" dirs = "6" +rand = "0.9" [dev-dependencies] tempfile = "3.10" diff --git a/src/app.rs b/src/app.rs index 40934b4..b4e9ccf 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,6 +3,9 @@ use tokio::sync::mpsc; use crate::api::client::ApiClient; use crate::event::AppEvent; +use crate::models::battle::{ + self, BattleMove, BattlePhase, BattlePokemon, BattleState, BattleLogEntry, +}; use crate::models::pokemon::{MoveDetail, PokemonDetail, PokemonSummary}; use crate::models::team::{Team, TeamData, TeamMember, TeamMove}; use crate::models::type_data::TypeInfo; @@ -33,6 +36,7 @@ pub enum Screen { PokemonDetail, TypeChart, TeamBuilder, + BattleSimulator, } impl Screen { @@ -42,6 +46,7 @@ impl Screen { Screen::PokemonDetail, Screen::TypeChart, Screen::TeamBuilder, + Screen::BattleSimulator, ] } @@ -51,6 +56,7 @@ impl Screen { Screen::PokemonDetail => "Detail", Screen::TypeChart => "Type Chart", Screen::TeamBuilder => "Team Builder", + Screen::BattleSimulator => "⚔ Battle", } } @@ -60,6 +66,7 @@ impl Screen { Screen::PokemonDetail => 1, Screen::TypeChart => 2, Screen::TeamBuilder => 3, + Screen::BattleSimulator => 4, } } } @@ -115,6 +122,9 @@ pub struct App { pub available_moves: Vec, pub moves_loading: LoadingState, + // Battle simulator + pub battle: BattleState, + pub error_message: Option, tx: mpsc::UnboundedSender, } @@ -147,6 +157,7 @@ impl App { modal_search: String::new(), available_moves: Vec::new(), moves_loading: LoadingState::Idle, + battle: BattleState::default(), error_message: None, tx, } @@ -183,6 +194,7 @@ impl App { modal_search: String::new(), available_moves: Vec::new(), moves_loading: LoadingState::Idle, + battle: BattleState::default(), error_message: None, tx, } @@ -452,12 +464,15 @@ impl App { self.on_screen_enter(); return; } - KeyCode::Char(c @ '1'..='4') + KeyCode::Char(c @ '1'..='5') if !self.search_mode && self.screen != Screen::PokemonList => { let idx = (c as usize) - ('1' as usize); - self.screen = Screen::all()[idx]; - self.on_screen_enter(); + let screens = Screen::all(); + if idx < screens.len() { + self.screen = screens[idx]; + self.on_screen_enter(); + } return; } _ => {} @@ -469,6 +484,7 @@ impl App { Screen::PokemonDetail => self.handle_detail_key(key), Screen::TypeChart => self.handle_type_chart_key(key), Screen::TeamBuilder => self.handle_team_key(key), + Screen::BattleSimulator => self.handle_battle_key(key), } } @@ -486,6 +502,9 @@ impl App { } Screen::TypeChart => self.load_types(), Screen::TeamBuilder => {} + Screen::BattleSimulator => { + self.start_loading_list(); // need pokemon list for picker + } } } @@ -712,6 +731,405 @@ impl App { } } + fn handle_battle_key(&mut self, key: KeyEvent) { + match self.battle.phase { + BattlePhase::SelectPokemon1 | BattlePhase::SelectPokemon2 => { + if self.battle.picker_search_mode { + match key.code { + KeyCode::Esc => self.battle.picker_search_mode = false, + KeyCode::Enter => self.battle.picker_search_mode = false, + KeyCode::Backspace => { + self.battle.picker_search.pop(); + self.battle.picker_selected = 0; + } + KeyCode::Char(c) => { + self.battle.picker_search.push(c); + self.battle.picker_selected = 0; + } + _ => {} + } + return; + } + + match key.code { + KeyCode::Char('/') => { + self.battle.picker_search_mode = true; + self.battle.picker_search.clear(); + } + KeyCode::Up | KeyCode::Char('k') => { + if self.battle.picker_selected > 0 { + self.battle.picker_selected -= 1; + } + } + KeyCode::Down | KeyCode::Char('j') => { + let max = self.battle_picker_list().len().saturating_sub(1); + if self.battle.picker_selected < max { + self.battle.picker_selected += 1; + } + } + KeyCode::Esc => { + self.battle.reset(); + } + KeyCode::Enter => { + let list = self.battle_picker_list(); + if let Some(p) = list.get(self.battle.picker_selected).cloned() { + let bp = self.build_battle_pokemon(&p); + if self.battle.phase == BattlePhase::SelectPokemon1 { + self.battle.log.push(BattleLogEntry { + text: format!("Player chose {}!", bp.name), + }); + self.battle.pokemon1 = Some(bp); + self.battle.phase = BattlePhase::SelectPokemon2; + self.battle.picker_selected = 0; + self.battle.picker_search.clear(); + self.battle.log.push(BattleLogEntry { + text: "Now pick the opponent Pokémon!".into(), + }); + } else { + self.battle.log.push(BattleLogEntry { + text: format!("Opponent is {}!", bp.name), + }); + self.battle.pokemon2 = Some(bp); + self.battle.phase = BattlePhase::SelectMove; + self.battle.selected_move = 0; + self.battle.turn = 1; + self.battle.log.push(BattleLogEntry { + text: "Battle start! Choose your move.".into(), + }); + } + } + } + _ => {} + } + } + BattlePhase::SelectMove => { + let move_count = self + .battle + .pokemon1 + .as_ref() + .map(|p| p.moves.len()) + .unwrap_or(0); + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + if self.battle.selected_move > 0 { + self.battle.selected_move -= 1; + } + } + KeyCode::Down | KeyCode::Char('j') => { + if self.battle.selected_move < move_count.saturating_sub(1) { + self.battle.selected_move += 1; + } + } + KeyCode::Enter => { + self.execute_battle_turn(); + } + KeyCode::Esc => { + self.battle.reset(); + } + _ => {} + } + } + BattlePhase::Animating => { + // Tick the animation forward on any key + self.tick_battle_animation(); + } + BattlePhase::Finished => { + match key.code { + KeyCode::Enter | KeyCode::Char('r') => { + self.battle.reset(); + } + _ => {} + } + } + } + } + + /// Get filtered pokemon list for battle picker (includes team members first) + pub fn battle_picker_list(&self) -> Vec { + let search = self.battle.picker_search.to_lowercase(); + let mut results: Vec = Vec::new(); + + // Add team members first + let team = self.current_team(); + for m in &team.members { + results.push(PokemonSummary { + id: m.pokemon_id, + name: format!("⭐ {}", m.pokemon_name), + types: m.types.clone(), + }); + } + + // Then all pokemon + for p in &self.pokemon_list { + results.push(p.clone()); + } + + if !search.is_empty() { + results.retain(|p| { + p.name.to_lowercase().contains(&search) || p.id.to_string().contains(&search) + }); + } + + results + } + + fn build_battle_pokemon(&self, summary: &PokemonSummary) -> BattlePokemon { + let name = summary.name.replace("⭐ ", ""); + let types = summary.types.clone(); + + // Try to get real stats from loaded detail + let (hp, atk, def, special, spd) = + if let Some(ref detail) = self.detail { + if detail.name == name { + self.extract_stats(detail) + } else { + self.default_stats_for_id(summary.id) + } + } else { + self.default_stats_for_id(summary.id) + }; + + // Check if team member has moves + let team = self.current_team(); + let team_moves: Vec = team + .members + .iter() + .find(|m| m.pokemon_name == name) + .map(|m| { + m.moves + .iter() + .filter_map(|tm| { + Some(BattleMove { + name: tm.name.clone(), + move_type: tm.move_type.clone(), + power: tm.power.unwrap_or(40), + accuracy: 100, + is_special: battle::is_special_type(&tm.move_type), + }) + }) + .collect() + }) + .unwrap_or_default(); + + let moves = if team_moves.is_empty() { + battle::default_moves_for_types(&types) + } else { + team_moves + }; + + let level = 50; + // Gen 1 HP formula: ((Base*2 + IV + EV/4) * Level) / 100 + Level + 10 + // Simplified: use base stat scaled + let max_hp = (hp * 2 * level) / 100 + level + 10; + + BattlePokemon { + name, + types, + max_hp, + current_hp: max_hp, + attack: (atk * 2 * level) / 100 + 5, + defense: (def * 2 * level) / 100 + 5, + special: (special * 2 * level) / 100 + 5, + speed: (spd * 2 * level) / 100 + 5, + level, + moves, + } + } + + fn extract_stats(&self, detail: &PokemonDetail) -> (u32, u32, u32, u32, u32) { + let get = |name: &str| -> u32 { + detail + .stats + .iter() + .find(|s| s.stat.name == name) + .map(|s| s.base_stat) + .unwrap_or(50) + }; + let sp_atk = get("special-attack"); + let sp_def = get("special-defense"); + ( + get("hp"), + get("attack"), + get("defense"), + (sp_atk + sp_def) / 2, // Gen 1 combined Special + get("speed"), + ) + } + + fn default_stats_for_id(&self, id: u32) -> (u32, u32, u32, u32, u32) { + // Reasonable defaults based on Pokemon ID ranges + match id { + 1..=151 => (65, 65, 65, 65, 65), // Gen 1 average + 152..=251 => (70, 70, 70, 70, 70), // Gen 2 + _ => (75, 75, 75, 75, 75), + } + } + + fn execute_battle_turn(&mut self) { + let p1 = self.battle.pokemon1.clone().unwrap(); + let p2 = self.battle.pokemon2.clone().unwrap(); + let p1_move_idx = self.battle.selected_move; + let p2_move_idx = battle::ai_pick_move(&p2, &p1); + + self.battle.turn += 1; + self.battle.log.push(BattleLogEntry { + text: format!("── Turn {} ──", self.battle.turn - 1), + }); + + let first = battle::first_attacker(&p1, &p2); + + let (a1, d1, m1_idx, a2, d2, m2_idx) = if first == 1 { + (&p1, &p2, p1_move_idx, &p2, &p1, p2_move_idx) + } else { + (&p2, &p1, p2_move_idx, &p1, &p2, p1_move_idx) + }; + + // First attack + if let Some(m) = a1.moves.get(m1_idx) { + let (dmg, eff) = battle::calculate_damage(a1, d1, m); + self.battle.log.push(BattleLogEntry { + text: format!("{} used {}!", a1.name, m.name), + }); + if let Some(msg) = battle::effectiveness_message(eff) { + self.battle.log.push(BattleLogEntry { + text: msg.to_string(), + }); + } + self.battle.log.push(BattleLogEntry { + text: format!("It dealt {} damage!", dmg), + }); + + // Apply damage + if first == 1 { + let hp = &mut self.battle.pokemon2.as_mut().unwrap().current_hp; + *hp = hp.saturating_sub(dmg); + } else { + let hp = &mut self.battle.pokemon1.as_mut().unwrap().current_hp; + *hp = hp.saturating_sub(dmg); + } + } + + // Check if defender fainted + let d1_hp = if first == 1 { + self.battle.pokemon2.as_ref().unwrap().current_hp + } else { + self.battle.pokemon1.as_ref().unwrap().current_hp + }; + + if d1_hp == 0 { + let winner = first; + let loser_name = d1.name.clone(); + self.battle.log.push(BattleLogEntry { + text: format!("{} fainted!", loser_name), + }); + self.battle.winner = Some(winner); + self.battle.phase = BattlePhase::Animating; + self.battle.anim_ticks_remaining = 6; + self.battle.anim_p1_target_hp = self.battle.pokemon1.as_ref().unwrap().current_hp; + self.battle.anim_p2_target_hp = self.battle.pokemon2.as_ref().unwrap().current_hp; + return; + } + + // Second attack + if let Some(m) = a2.moves.get(m2_idx) { + let d2_current = if first == 1 { + // a2 is p2, defender is now p1 with its current HP + self.battle.pokemon1.as_ref().unwrap() + } else { + self.battle.pokemon2.as_ref().unwrap() + }; + let (dmg, eff) = battle::calculate_damage(a2, d2_current, m); + self.battle.log.push(BattleLogEntry { + text: format!("{} used {}!", a2.name, m.name), + }); + if let Some(msg) = battle::effectiveness_message(eff) { + self.battle.log.push(BattleLogEntry { + text: msg.to_string(), + }); + } + self.battle.log.push(BattleLogEntry { + text: format!("It dealt {} damage!", dmg), + }); + + if first == 1 { + let hp = &mut self.battle.pokemon1.as_mut().unwrap().current_hp; + *hp = hp.saturating_sub(dmg); + } else { + let hp = &mut self.battle.pokemon2.as_mut().unwrap().current_hp; + *hp = hp.saturating_sub(dmg); + } + } + + // Check second faint + let d2_hp = if first == 1 { + self.battle.pokemon1.as_ref().unwrap().current_hp + } else { + self.battle.pokemon2.as_ref().unwrap().current_hp + }; + + if d2_hp == 0 { + let winner = if first == 1 { 2u8 } else { 1u8 }; + let loser_name = d2.name.clone(); + self.battle.log.push(BattleLogEntry { + text: format!("{} fainted!", loser_name), + }); + self.battle.winner = Some(winner); + self.battle.phase = BattlePhase::Animating; + self.battle.anim_ticks_remaining = 6; + } else { + // Continue battle + self.battle.phase = BattlePhase::Animating; + self.battle.anim_ticks_remaining = 3; + } + + self.battle.anim_p1_target_hp = self.battle.pokemon1.as_ref().unwrap().current_hp; + self.battle.anim_p2_target_hp = self.battle.pokemon2.as_ref().unwrap().current_hp; + } + + fn tick_battle_animation(&mut self) { + if self.battle.anim_ticks_remaining > 0 { + self.battle.anim_ticks_remaining -= 1; + // Smoothly drain HP toward target + if let Some(ref mut p) = self.battle.pokemon1 { + if p.current_hp > self.battle.anim_p1_target_hp { + let diff = p.current_hp - self.battle.anim_p1_target_hp; + p.current_hp -= (diff / (self.battle.anim_ticks_remaining as u32 + 1)).max(1); + } + } + if let Some(ref mut p) = self.battle.pokemon2 { + if p.current_hp > self.battle.anim_p2_target_hp { + let diff = p.current_hp - self.battle.anim_p2_target_hp; + p.current_hp -= (diff / (self.battle.anim_ticks_remaining as u32 + 1)).max(1); + } + } + } + + if self.battle.anim_ticks_remaining == 0 { + // Snap to target + if let Some(ref mut p) = self.battle.pokemon1 { + p.current_hp = self.battle.anim_p1_target_hp; + } + if let Some(ref mut p) = self.battle.pokemon2 { + p.current_hp = self.battle.anim_p2_target_hp; + } + + if self.battle.winner.is_some() { + let winner_name = if self.battle.winner == Some(1) { + self.battle.pokemon1.as_ref().unwrap().name.clone() + } else { + self.battle.pokemon2.as_ref().unwrap().name.clone() + }; + self.battle.log.push(BattleLogEntry { + text: format!("🏆 {} wins the battle!", winner_name), + }); + self.battle.phase = BattlePhase::Finished; + } else { + self.battle.phase = BattlePhase::SelectMove; + self.battle.selected_move = 0; + } + } + } + fn handle_modal_key(&mut self, key: KeyEvent, modal: Modal) { if key.code == KeyCode::Esc { self.modal = None; @@ -908,11 +1326,12 @@ mod tests { #[test] fn test_screen_all() { let screens = Screen::all(); - assert_eq!(screens.len(), 4); + assert_eq!(screens.len(), 5); assert_eq!(screens[0], Screen::PokemonList); assert_eq!(screens[1], Screen::PokemonDetail); assert_eq!(screens[2], Screen::TypeChart); assert_eq!(screens[3], Screen::TeamBuilder); + assert_eq!(screens[4], Screen::BattleSimulator); } #[test] @@ -921,6 +1340,7 @@ mod tests { assert_eq!(Screen::PokemonDetail.label(), "Detail"); assert_eq!(Screen::TypeChart.label(), "Type Chart"); assert_eq!(Screen::TeamBuilder.label(), "Team Builder"); + assert_eq!(Screen::BattleSimulator.label(), "⚔ Battle"); } #[test] @@ -929,6 +1349,7 @@ mod tests { assert_eq!(Screen::PokemonDetail.index(), 1); assert_eq!(Screen::TypeChart.index(), 2); assert_eq!(Screen::TeamBuilder.index(), 3); + assert_eq!(Screen::BattleSimulator.index(), 4); } #[test] diff --git a/src/models/battle.rs b/src/models/battle.rs new file mode 100644 index 0000000..f9e51ea --- /dev/null +++ b/src/models/battle.rs @@ -0,0 +1,359 @@ +use rand::Rng; + +/// A Pokémon prepared for battle with base stats and moves. +#[derive(Debug, Clone)] +pub struct BattlePokemon { + pub name: String, + pub types: Vec, + pub max_hp: u32, + pub current_hp: u32, + pub attack: u32, + pub defense: u32, + pub special: u32, // Gen 1 combined Special + pub speed: u32, + pub level: u32, + pub moves: Vec, +} + +#[derive(Debug, Clone)] +pub struct BattleMove { + pub name: String, + pub move_type: String, + pub power: u32, + pub accuracy: u32, + pub is_special: bool, // true = special, false = physical +} + +#[derive(Debug, Clone)] +pub struct BattleLogEntry { + pub text: String, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum BattlePhase { + /// Picking first Pokémon (player 1) + SelectPokemon1, + /// Picking second Pokémon (player 2 / AI) + SelectPokemon2, + /// Player picks a move + SelectMove, + /// Animating a turn (ticks drain HP bars) + Animating, + /// Battle is over + Finished, +} + +#[derive(Debug, Clone)] +pub struct BattleState { + pub phase: BattlePhase, + pub pokemon1: Option, + pub pokemon2: Option, + pub log: Vec, + pub selected_move: usize, + pub turn: u32, + pub winner: Option, // 1 or 2 + // picker state + pub picker_selected: usize, + pub picker_search: String, + pub picker_search_mode: bool, + // animation + pub anim_ticks_remaining: u8, + pub anim_p1_target_hp: u32, + pub anim_p2_target_hp: u32, +} + +impl Default for BattleState { + fn default() -> Self { + Self { + phase: BattlePhase::SelectPokemon1, + pokemon1: None, + pokemon2: None, + log: vec![BattleLogEntry { + text: "Welcome to the Battle Simulator! Pick your Pokémon.".into(), + }], + selected_move: 0, + turn: 0, + winner: None, + picker_selected: 0, + picker_search: String::new(), + picker_search_mode: false, + anim_ticks_remaining: 0, + anim_p1_target_hp: 0, + anim_p2_target_hp: 0, + } + } +} + +impl BattleState { + pub fn reset(&mut self) { + *self = Self::default(); + } +} + +// ── Gen 1 type effectiveness ──────────────────────────────────────── + +pub fn type_effectiveness(attack_type: &str, defend_type: &str) -> f64 { + match (attack_type, defend_type) { + // Normal + ("normal", "rock") | ("normal", "steel") => 0.5, + ("normal", "ghost") => 0.0, + // Fire + ("fire", "grass") | ("fire", "ice") | ("fire", "bug") | ("fire", "steel") => 2.0, + ("fire", "fire") | ("fire", "water") | ("fire", "rock") | ("fire", "dragon") => 0.5, + // Water + ("water", "fire") | ("water", "ground") | ("water", "rock") => 2.0, + ("water", "water") | ("water", "grass") | ("water", "dragon") => 0.5, + // Electric + ("electric", "water") | ("electric", "flying") => 2.0, + ("electric", "electric") | ("electric", "grass") | ("electric", "dragon") => 0.5, + ("electric", "ground") => 0.0, + // Grass + ("grass", "water") | ("grass", "ground") | ("grass", "rock") => 2.0, + ("grass", "fire") + | ("grass", "grass") + | ("grass", "poison") + | ("grass", "flying") + | ("grass", "bug") + | ("grass", "dragon") + | ("grass", "steel") => 0.5, + // Ice + ("ice", "grass") | ("ice", "ground") | ("ice", "flying") | ("ice", "dragon") => 2.0, + ("ice", "fire") | ("ice", "water") | ("ice", "ice") | ("ice", "steel") => 0.5, + // Fighting + ("fighting", "normal") + | ("fighting", "ice") + | ("fighting", "rock") + | ("fighting", "dark") + | ("fighting", "steel") => 2.0, + ("fighting", "poison") + | ("fighting", "flying") + | ("fighting", "psychic") + | ("fighting", "bug") + | ("fighting", "fairy") => 0.5, + ("fighting", "ghost") => 0.0, + // Poison + ("poison", "grass") | ("poison", "fairy") => 2.0, + ("poison", "poison") | ("poison", "ground") | ("poison", "rock") | ("poison", "ghost") => { + 0.5 + } + ("poison", "steel") => 0.0, + // Ground + ("ground", "fire") + | ("ground", "electric") + | ("ground", "poison") + | ("ground", "rock") + | ("ground", "steel") => 2.0, + ("ground", "grass") | ("ground", "bug") => 0.5, + ("ground", "flying") => 0.0, + // Flying + ("flying", "grass") | ("flying", "fighting") | ("flying", "bug") => 2.0, + ("flying", "electric") | ("flying", "rock") | ("flying", "steel") => 0.5, + // Psychic + ("psychic", "fighting") | ("psychic", "poison") => 2.0, + ("psychic", "psychic") | ("psychic", "steel") => 0.5, + ("psychic", "dark") => 0.0, + // Bug + ("bug", "grass") | ("bug", "psychic") | ("bug", "dark") => 2.0, + ("bug", "fire") + | ("bug", "fighting") + | ("bug", "poison") + | ("bug", "flying") + | ("bug", "ghost") + | ("bug", "steel") + | ("bug", "fairy") => 0.5, + // Rock + ("rock", "fire") | ("rock", "ice") | ("rock", "flying") | ("rock", "bug") => 2.0, + ("rock", "fighting") | ("rock", "ground") | ("rock", "steel") => 0.5, + // Ghost + ("ghost", "psychic") | ("ghost", "ghost") => 2.0, + ("ghost", "dark") => 0.5, + ("ghost", "normal") => 0.0, + // Dragon + ("dragon", "dragon") => 2.0, + ("dragon", "steel") => 0.5, + ("dragon", "fairy") => 0.0, + // Dark + ("dark", "psychic") | ("dark", "ghost") => 2.0, + ("dark", "fighting") | ("dark", "dark") | ("dark", "fairy") => 0.5, + // Steel + ("steel", "ice") | ("steel", "rock") | ("steel", "fairy") => 2.0, + ("steel", "fire") | ("steel", "water") | ("steel", "electric") | ("steel", "steel") => 0.5, + // Fairy + ("fairy", "fighting") | ("fairy", "dragon") | ("fairy", "dark") => 2.0, + ("fairy", "fire") | ("fairy", "poison") | ("fairy", "steel") => 0.5, + // Default neutral + _ => 1.0, + } +} + +/// Calculate combined type effectiveness against a defender with 1-2 types. +pub fn combined_effectiveness(attack_type: &str, defender_types: &[String]) -> f64 { + defender_types + .iter() + .map(|t| type_effectiveness(attack_type, t)) + .product() +} + +/// Effectiveness message +pub fn effectiveness_message(multiplier: f64) -> Option<&'static str> { + if multiplier >= 2.0 { + Some("It's super effective!") + } else if multiplier > 0.0 && multiplier < 1.0 { + Some("It's not very effective...") + } else if multiplier == 0.0 { + Some("It doesn't affect the opponent...") + } else { + None + } +} + +/// Gen 1 damage formula (simplified): +/// ((2*Level/5+2) * Power * A/D) / 50 + 2) * STAB * Type * random(0.85..1.0) +pub fn calculate_damage( + attacker: &BattlePokemon, + defender: &BattlePokemon, + battle_move: &BattleMove, +) -> (u32, f64) { + if battle_move.power == 0 { + return (0, 1.0); + } + + let level = attacker.level as f64; + let power = battle_move.power as f64; + + let (atk_stat, def_stat) = if battle_move.is_special { + (attacker.special as f64, defender.special as f64) + } else { + (attacker.attack as f64, defender.defense as f64) + }; + + let def_stat = def_stat.max(1.0); + + // STAB + let stab = if attacker.types.contains(&battle_move.move_type) { + 1.5 + } else { + 1.0 + }; + + // Type effectiveness + let type_mult = combined_effectiveness(&battle_move.move_type, &defender.types); + + // Random factor 0.85-1.0 + let mut rng = rand::rng(); + let random: f64 = rng.random_range(0.85..=1.0); + + let base = ((2.0 * level / 5.0 + 2.0) * power * atk_stat / def_stat) / 50.0 + 2.0; + let damage = (base * stab * type_mult * random).floor() as u32; + + (damage.max(1), type_mult) +} + +/// Simple AI: pick the move that does the most expected damage, with some randomness. +pub fn ai_pick_move(attacker: &BattlePokemon, defender: &BattlePokemon) -> usize { + if attacker.moves.is_empty() { + return 0; + } + + let mut best_idx = 0; + let mut best_score = 0.0f64; + + for (i, m) in attacker.moves.iter().enumerate() { + let eff = combined_effectiveness(&m.move_type, &defender.types); + let stab = if attacker.types.contains(&m.move_type) { + 1.5 + } else { + 1.0 + }; + let score = m.power as f64 * eff * stab; + if score > best_score { + best_score = score; + best_idx = i; + } + } + + // 30% chance to pick a random move for variety + let mut rng = rand::rng(); + if rng.random_range(0..10) < 3 { + rng.random_range(0..attacker.moves.len()) + } else { + best_idx + } +} + +/// Determine who goes first based on speed (ties: player 1 advantage). +pub fn first_attacker(p1: &BattlePokemon, p2: &BattlePokemon) -> u8 { + if p1.speed >= p2.speed { + 1 + } else { + 2 + } +} + +/// Gen 1: physical vs special is determined by move type +pub fn is_special_type(type_name: &str) -> bool { + matches!( + type_name, + "water" | "fire" | "grass" | "electric" | "ice" | "psychic" | "dragon" + ) +} + +/// Build default moves for a Pokémon if it has none assigned (fallback) +pub fn default_moves_for_types(types: &[String]) -> Vec { + let mut moves = Vec::new(); + + // STAB moves based on type + for t in types.iter().take(2) { + let (name, power) = match t.as_str() { + "normal" => ("Tackle", 40), + "fire" => ("Flamethrower", 90), + "water" => ("Surf", 90), + "electric" => ("Thunderbolt", 90), + "grass" => ("Razor Leaf", 55), + "ice" => ("Ice Beam", 90), + "fighting" => ("Karate Chop", 50), + "poison" => ("Sludge Bomb", 90), + "ground" => ("Earthquake", 100), + "flying" => ("Aerial Ace", 60), + "psychic" => ("Psychic", 90), + "bug" => ("Bug Buzz", 90), + "rock" => ("Rock Slide", 75), + "ghost" => ("Shadow Ball", 80), + "dragon" => ("Dragon Pulse", 85), + "dark" => ("Dark Pulse", 80), + "steel" => ("Iron Tail", 100), + "fairy" => ("Moonblast", 95), + _ => ("Tackle", 40), + }; + moves.push(BattleMove { + name: name.to_string(), + move_type: t.clone(), + power, + accuracy: 100, + is_special: is_special_type(t), + }); + } + + // Fill remaining slots with Normal move + Struggle + if moves.len() < 4 { + moves.push(BattleMove { + name: "Body Slam".to_string(), + move_type: "normal".to_string(), + power: 85, + accuracy: 100, + is_special: false, + }); + } + if moves.len() < 4 { + moves.push(BattleMove { + name: "Hyper Beam".to_string(), + move_type: "normal".to_string(), + power: 150, + accuracy: 90, + is_special: false, + }); + } + + moves.truncate(4); + moves +} diff --git a/src/models/mod.rs b/src/models/mod.rs index a00542d..cc2a2fc 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,3 +1,4 @@ +pub mod battle; pub mod pokemon; pub mod team; pub mod type_data; diff --git a/src/ui/battle_simulator.rs b/src/ui/battle_simulator.rs new file mode 100644 index 0000000..adc3ba8 --- /dev/null +++ b/src/ui/battle_simulator.rs @@ -0,0 +1,301 @@ +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Gauge, List, ListItem, Paragraph, Wrap}; +use ratatui::Frame; + +use crate::app::App; +use crate::models::battle::{BattlePhase, BattlePokemon}; +use crate::ui::type_color; + +pub fn draw(f: &mut Frame, app: &App, area: Rect) { + let battle = &app.battle; + + match battle.phase { + BattlePhase::SelectPokemon1 | BattlePhase::SelectPokemon2 => { + draw_pokemon_picker(f, app, area); + } + BattlePhase::SelectMove | BattlePhase::Animating | BattlePhase::Finished => { + draw_battle_arena(f, app, area); + } + } +} + +fn draw_pokemon_picker(f: &mut Frame, app: &App, area: Rect) { + let battle = &app.battle; + let title = if battle.phase == BattlePhase::SelectPokemon1 { + " ⚔ Pick Player 1's Pokémon " + } else { + " ⚔ Pick Player 2's Pokémon (Opponent) " + }; + + let chunks = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).split(area); + + // Search bar + let search_text = if battle.picker_search_mode { + format!("Search: {}▏", battle.picker_search) + } else if battle.picker_search.is_empty() { + "Press / to search | ⭐ = Team members".to_string() + } else { + format!("Search: {} (press / to edit)", battle.picker_search) + }; + let search = Paragraph::new(search_text).block( + Block::default() + .borders(Borders::ALL) + .title(title) + .title_style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), + ); + f.render_widget(search, chunks[0]); + + // Pokemon list + let list_data = app.battle_picker_list(); + let items: Vec = list_data + .iter() + .enumerate() + .map(|(i, p)| { + let style = if i == battle.picker_selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else if p.name.starts_with('⭐') { + Style::default().fg(Color::Cyan) + } else { + Style::default() + }; + + let type_str = if p.types.is_empty() { + String::new() + } else { + format!(" [{}]", p.types.join("/")) + }; + + ListItem::new(format!( + " {} #{:04} {}{}", + if i == battle.picker_selected { + "▸" + } else { + " " + }, + p.id, + p.name, + type_str + )) + .style(style) + }) + .collect(); + + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .title(" Pokémon List ") + .border_style(Style::default().fg(Color::DarkGray)), + ); + f.render_widget(list, chunks[1]); +} + +fn draw_battle_arena(f: &mut Frame, app: &App, area: Rect) { + let battle = &app.battle; + + // Layout: [P1 info | P2 info] on top, [moves | battle log] on bottom + let main_chunks = + Layout::vertical([Constraint::Length(8), Constraint::Min(0)]).split(area); + + let top_chunks = + Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(main_chunks[0]); + + let bottom_chunks = + Layout::horizontal([Constraint::Percentage(35), Constraint::Percentage(65)]) + .split(main_chunks[1]); + + // Draw Pokémon panels + if let Some(ref p1) = battle.pokemon1 { + draw_pokemon_panel(f, p1, "Player 1", top_chunks[0], Color::Cyan); + } + if let Some(ref p2) = battle.pokemon2 { + draw_pokemon_panel(f, p2, "Opponent", top_chunks[1], Color::Red); + } + + // Draw move selection + draw_move_panel(f, app, bottom_chunks[0]); + + // Draw battle log + draw_battle_log(f, app, bottom_chunks[1]); +} + +fn draw_pokemon_panel(f: &mut Frame, pokemon: &BattlePokemon, label: &str, area: Rect, color: Color) { + let inner = Layout::vertical([ + Constraint::Length(1), // Name + types + Constraint::Length(2), // HP bar + Constraint::Length(1), // Stats line 1 + Constraint::Length(1), // Stats line 2 + Constraint::Min(0), + ]) + .split( + Block::default() + .borders(Borders::ALL) + .title(format!(" {} ", label)) + .title_style(Style::default().fg(color).add_modifier(Modifier::BOLD)) + .inner(area), + ); + + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" {} ", label)) + .title_style(Style::default().fg(color).add_modifier(Modifier::BOLD)); + f.render_widget(block, area); + + // Name + types + let type_spans: Vec = pokemon + .types + .iter() + .map(|t| { + Span::styled( + format!(" {} ", t.to_uppercase()), + Style::default() + .fg(Color::White) + .bg(type_color(t)) + .add_modifier(Modifier::BOLD), + ) + }) + .collect(); + + let mut name_line = vec![Span::styled( + format!(" {} ", pokemon.name.to_uppercase()), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + )]; + name_line.extend(type_spans); + f.render_widget(Paragraph::new(Line::from(name_line)), inner[0]); + + // HP bar + let hp_pct = if pokemon.max_hp > 0 { + (pokemon.current_hp as f64 / pokemon.max_hp as f64 * 100.0) as u16 + } else { + 0 + }; + let hp_color = match hp_pct { + 0..=25 => Color::Red, + 26..=50 => Color::Yellow, + _ => Color::Green, + }; + let gauge = Gauge::default() + .gauge_style(Style::default().fg(hp_color).bg(Color::DarkGray)) + .percent(hp_pct.min(100)) + .label(format!("HP: {}/{}", pokemon.current_hp, pokemon.max_hp)); + f.render_widget(gauge, inner[1]); + + // Stats + let stats1 = Paragraph::new(Line::from(vec![ + Span::styled(" ATK:", Style::default().fg(Color::Red)), + Span::raw(format!("{} ", pokemon.attack)), + Span::styled("DEF:", Style::default().fg(Color::Blue)), + Span::raw(format!("{} ", pokemon.defense)), + Span::styled("SPD:", Style::default().fg(Color::Green)), + Span::raw(format!("{}", pokemon.speed)), + ])); + f.render_widget(stats1, inner[2]); + + let stats2 = Paragraph::new(Line::from(vec![ + Span::styled(" SPC:", Style::default().fg(Color::Magenta)), + Span::raw(format!("{} ", pokemon.special)), + Span::styled("LVL:", Style::default().fg(Color::Yellow)), + Span::raw(format!("{}", pokemon.level)), + ])); + f.render_widget(stats2, inner[3]); +} + +fn draw_move_panel(f: &mut Frame, app: &App, area: Rect) { + let battle = &app.battle; + let is_selecting = battle.phase == BattlePhase::SelectMove; + + let moves = battle + .pokemon1 + .as_ref() + .map(|p| &p.moves[..]) + .unwrap_or(&[]); + + let items: Vec = moves + .iter() + .enumerate() + .map(|(i, m)| { + let selected = is_selecting && i == battle.selected_move; + let arrow = if selected { "▸" } else { " " }; + let style = if selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + + let type_style = Style::default() + .fg(type_color(&m.move_type)) + .add_modifier(Modifier::BOLD); + + ListItem::new(Line::from(vec![ + Span::raw(format!(" {} ", arrow)), + Span::styled(format!("{:<14}", m.name), style), + Span::styled(format!(" {:>8} ", m.move_type.to_uppercase()), type_style), + Span::styled(format!("PWR:{}", m.power), Style::default().fg(Color::White)), + ])) + }) + .collect(); + + let title = match battle.phase { + BattlePhase::SelectMove => " Moves (↑↓ Enter) ", + BattlePhase::Animating => " ⚡ Battling... ", + BattlePhase::Finished => " Press R to rematch ", + _ => " Moves ", + }; + + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .title(title) + .title_style(Style::default().fg(Color::Yellow)), + ); + f.render_widget(list, area); +} + +fn draw_battle_log(f: &mut Frame, app: &App, area: Rect) { + let battle = &app.battle; + + // Show last N log entries that fit + let max_lines = area.height.saturating_sub(2) as usize; + let start = battle.log.len().saturating_sub(max_lines); + let visible: Vec = battle.log[start..] + .iter() + .map(|entry| { + let style = if entry.text.contains("super effective") { + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) + } else if entry.text.contains("not very effective") { + Style::default().fg(Color::Red) + } else if entry.text.contains("doesn't affect") { + Style::default().fg(Color::DarkGray) + } else if entry.text.contains("fainted") { + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) + } else if entry.text.contains('🏆') { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else if entry.text.starts_with("──") { + Style::default().fg(Color::DarkGray) + } else { + Style::default() + }; + Line::styled(format!(" {}", entry.text), style) + }) + .collect(); + + let log = Paragraph::new(visible) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Battle Log ") + .title_style(Style::default().fg(Color::Magenta)), + ) + .wrap(Wrap { trim: true }); + f.render_widget(log, area); +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 677281a..be4858e 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,3 +1,4 @@ +pub mod battle_simulator; pub mod pokemon_detail; pub mod pokemon_list; pub mod team_builder; @@ -21,6 +22,7 @@ pub fn draw(f: &mut Frame, app: &App) { Screen::PokemonDetail => pokemon_detail::draw(f, app, chunks[1]), Screen::TypeChart => type_chart::draw(f, app, chunks[1]), Screen::TeamBuilder => team_builder::draw(f, app, chunks[1]), + Screen::BattleSimulator => battle_simulator::draw(f, app, chunks[1]), } // Error overlay From 2483d7f58c07efd1bde83d120b5a7e30c209184d Mon Sep 17 00:00:00 2001 From: Luis Camargo Date: Fri, 13 Feb 2026 20:29:00 -0800 Subject: [PATCH 2/3] feat(battle): improve UX with hints bar, step indicator, status banner, move categories, VS display, and log scroll indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add persistent contextual hints bar at bottom of every battle screen state - Add step progress indicator (Step 1/2, 2/2) to Pokémon picker - Add prominent status banner showing current phase (YOUR TURN / Battle in progress / BATTLE OVER) - Show move category (⚔Physical/✦Special) with improved spacing in move panel - Add VS display in battle log when both Pokémon are selected - Add '▲ more above' indicator when battle log is truncated --- .DS_Store | Bin 6148 -> 6148 bytes src/app.rs | 12 ++++ src/ui/battle_simulator.rs | 129 +++++++++++++++++++++++++++++++++---- 3 files changed, 128 insertions(+), 13 deletions(-) diff --git a/.DS_Store b/.DS_Store index f98da7fbf5236a0d362da5c2165df024bba1055c..a1457b6980ba14c1e0ef3db6def2d0488f5c7681 100644 GIT binary patch delta 544 zcmZoMXfc=|#>B!ku~2NHo+2ar#(>?7i!U%UF|u#wVVcEQpP5`%kd%|3#K6FKHmM*d zv$({-;J$zWBNH!{synt|0VsdtRQGRJ& zigSKWevwm7X==Oxe{n{Bxo2{IUO-W5S!QZ^L`iBzNpNNrP)Z0*Lr`i;Tv9#UkdVp( zpdt}8MeaqZsd>@qMVTr7X=%l&B@syMNRV~OMfo{70r|z5C7JnokuhNFOHy+^^V0Hx zR)U=1UjULS4ofX62GVGH>htp=A`^>>67$kiohoB<5|dJM{PUdhOG@%{BQk;h0J$A( zXK+Dca%Nt-vi%1JFyK^n(15T67?dFl1_llh7z;`>Dj!I00JB5#^7B%GngSp)$`0Nj zMhp;hFlaM4G6VzJg$xZ0(-{^stY^_jOL6EjE;;! zjM0np6F G!wdjjOpiqX delta 68 zcmZoMXfc=|#>B)qu~2NHo+2aL#(>?7jBJ~ESY|O!?qsjqtibV@c{4i)KL=3FW { - draw_pokemon_picker(f, app, area); + draw_pokemon_picker(f, app, main_area); } BattlePhase::SelectMove | BattlePhase::Animating | BattlePhase::Finished => { - draw_battle_arena(f, app, area); + draw_battle_arena(f, app, main_area); } } + + // Draw contextual hints bar + let hints = match battle.phase { + BattlePhase::SelectPokemon1 | BattlePhase::SelectPokemon2 => { + if battle.picker_search_mode { + "Type to search | Enter Confirm | Esc Cancel" + } else { + "↑↓ Navigate | Enter Select | / Search | Esc Back" + } + } + BattlePhase::SelectMove => "↑↓ Pick move | Enter Attack | Esc Quit battle", + BattlePhase::Animating => "Waiting...", + BattlePhase::Finished => "R Rematch | Esc Back to picker", + }; + let hints_widget = Paragraph::new(Line::from(vec![ + Span::styled(" 💡 ", Style::default().fg(Color::Yellow)), + Span::styled(hints, Style::default().fg(Color::DarkGray)), + ])); + f.render_widget(hints_widget, hints_area); } fn draw_pokemon_picker(f: &mut Frame, app: &App, area: Rect) { let battle = &app.battle; - let title = if battle.phase == BattlePhase::SelectPokemon1 { - " ⚔ Pick Player 1's Pokémon " + let (step_label, title) = if battle.phase == BattlePhase::SelectPokemon1 { + ("Step 1/2", " ⚔ Choose YOUR Pokémon ") } else { - " ⚔ Pick Player 2's Pokémon (Opponent) " + ("Step 2/2", " ⚔ Choose OPPONENT ") }; - let chunks = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).split(area); + let chunks = Layout::vertical([ + Constraint::Length(1), // Step indicator + Constraint::Length(3), // Search bar + Constraint::Min(0), // Pokemon list + ]) + .split(area); + + // Step progress indicator + let step_bar = Paragraph::new(Line::from(vec![ + Span::styled( + format!(" {} ", step_label), + Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + if battle.phase == BattlePhase::SelectPokemon1 { + " ● ○ Choose YOUR Pokémon" + } else { + " ● ● Choose OPPONENT" + }, + Style::default().fg(Color::Yellow), + ), + ])); + f.render_widget(step_bar, chunks[0]); // Search bar let search_text = if battle.picker_search_mode { @@ -45,7 +94,7 @@ fn draw_pokemon_picker(f: &mut Frame, app: &App, area: Rect) { .title(title) .title_style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), ); - f.render_widget(search, chunks[0]); + f.render_widget(search, chunks[1]); // Pokemon list let list_data = app.battle_picker_list(); @@ -90,23 +139,58 @@ fn draw_pokemon_picker(f: &mut Frame, app: &App, area: Rect) { .title(" Pokémon List ") .border_style(Style::default().fg(Color::DarkGray)), ); - f.render_widget(list, chunks[1]); + f.render_widget(list, chunks[2]); } fn draw_battle_arena(f: &mut Frame, app: &App, area: Rect) { let battle = &app.battle; - // Layout: [P1 info | P2 info] on top, [moves | battle log] on bottom + // Layout: [P1 info | P2 info] on top, [status banner], [moves | battle log] on bottom let main_chunks = - Layout::vertical([Constraint::Length(8), Constraint::Min(0)]).split(area); + Layout::vertical([Constraint::Length(8), Constraint::Length(1), Constraint::Min(0)]) + .split(area); let top_chunks = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) .split(main_chunks[0]); + // Status banner + let (banner_text, banner_color) = match battle.phase { + BattlePhase::SelectMove => ("⚔ YOUR TURN — Choose a move!", Color::Yellow), + BattlePhase::Animating => ("⏳ Battle in progress...", Color::Cyan), + BattlePhase::Finished => { + let winner_name = if battle.winner == Some(1) { + battle.pokemon1.as_ref().map(|p| p.name.as_str()).unwrap_or("Player 1") + } else { + battle.pokemon2.as_ref().map(|p| p.name.as_str()).unwrap_or("Opponent") + }; + // Leak not ideal but this is a UI label rebuilt each frame + // Use a local string and convert + let _ = winner_name; // suppress unused + ("🏆 BATTLE OVER", Color::Green) + } + _ => ("", Color::Reset), + }; + // For Finished, build dynamic string + let banner_line = if battle.phase == BattlePhase::Finished { + let winner_name = if battle.winner == Some(1) { + battle.pokemon1.as_ref().map(|p| p.name.clone()).unwrap_or_else(|| "Player 1".into()) + } else { + battle.pokemon2.as_ref().map(|p| p.name.clone()).unwrap_or_else(|| "Opponent".into()) + }; + format!("🏆 BATTLE OVER — {} wins!", winner_name) + } else { + banner_text.to_string() + }; + let banner = Paragraph::new(Line::from(Span::styled( + format!(" {} ", banner_line), + Style::default().fg(Color::Black).bg(banner_color).add_modifier(Modifier::BOLD), + ))); + f.render_widget(banner, main_chunks[1]); + let bottom_chunks = Layout::horizontal([Constraint::Percentage(35), Constraint::Percentage(65)]) - .split(main_chunks[1]); + .split(main_chunks[2]); // Draw Pokémon panels if let Some(ref p1) = battle.pokemon1 { @@ -234,10 +318,20 @@ fn draw_move_panel(f: &mut Frame, app: &App, area: Rect) { .fg(type_color(&m.move_type)) .add_modifier(Modifier::BOLD); + let category_icon = if m.is_special { "✦" } else { "⚔" }; + let category_label = if m.is_special { "Spc" } else { "Phy" }; + ListItem::new(Line::from(vec![ Span::raw(format!(" {} ", arrow)), Span::styled(format!("{:<14}", m.name), style), - Span::styled(format!(" {:>8} ", m.move_type.to_uppercase()), type_style), + Span::raw(" "), + Span::styled(format!("{:>8}", m.move_type.to_uppercase()), type_style), + Span::raw(" "), + Span::styled( + format!("{}{}", category_icon, category_label), + Style::default().fg(Color::DarkGray), + ), + Span::raw(" "), Span::styled(format!("PWR:{}", m.power), Style::default().fg(Color::White)), ])) }) @@ -265,7 +359,15 @@ fn draw_battle_log(f: &mut Frame, app: &App, area: Rect) { // Show last N log entries that fit let max_lines = area.height.saturating_sub(2) as usize; let start = battle.log.len().saturating_sub(max_lines); - let visible: Vec = battle.log[start..] + let has_more_above = start > 0; + let mut visible: Vec = Vec::new(); + if has_more_above { + visible.push(Line::styled( + " ▲ more above ▲", + Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC), + )); + } + let log_lines: Vec = battle.log[start..] .iter() .map(|entry| { let style = if entry.text.contains("super effective") { @@ -288,6 +390,7 @@ fn draw_battle_log(f: &mut Frame, app: &App, area: Rect) { Line::styled(format!(" {}", entry.text), style) }) .collect(); + visible.extend(log_lines); let log = Paragraph::new(visible) .block( From 93d3cb50c293a0c39b3e51982e84e252b18be3f8 Mon Sep 17 00:00:00 2001 From: Luis Camargo Date: Sat, 14 Feb 2026 01:09:07 -0800 Subject: [PATCH 3/3] chore: remove .DS_Store and add to .gitignore --- .DS_Store | Bin 6148 -> 0 bytes .gitignore | 1 + 2 files changed, 1 insertion(+) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index a1457b6980ba14c1e0ef3db6def2d0488f5c7681..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKPfyf96o0d<(gl$MyNLgihP{|Tkbh!`2TOq%Vhn^5TmrbX-Lh7?owe<Pj)%9jogC#Gk{N0o`m*{P#Z<#2Wq z2h&qCM@Jbcm%n~%wYcY2Jo*gxjzaLfBh`Fxh;IHOEg_qm?) zy>egwKwq{mm&*-a7`oVh`ASh9Fb!+3?6aNDQEZEL@NWE%U zj#tj#{uU;Iz`s}lc0MTB7^(sr5%Qx0yA%$YJg?|VLq(t} zun{3zP)I~YjHpC>VvvZA^NR3O1vVm#=s?*GN#fB!d=l2KBDqyqm{1xR{9U(hfmaksW5$L?AM+YUA+%&!q4r(h?pW3I5P fcnBL8v?)wMR0TF7#2J+2j{q-2(j^u6qZRl8Mu7Px diff --git a/.gitignore b/.gitignore index ea8c4bf..0592392 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +.DS_Store