//! Screen-Stack und Antwort — Ticket 11, im Durchstich (12) bestaetigt. use ratatui::crossterm::event::KeyCode; use crate::data::Registries; use crate::game::{Attribute, Cheat, Command, CreatureRef, Direction, Game}; /// Popups sind gewoehnliche Screens, die sich beim Rendern als Overlay /// ausweisen — ein gemeinsamer `Vec` statt `current` plus /// `Option`, weil Popup ueber Popup real ist. #[derive(Clone)] pub enum Screen { MainMenu, Explore, /// Menue-Kampf (Charter): Faehigkeitsliste links, Gegnerliste rechts. Combat { /// Cursor in der Faehigkeitsliste. pick: usize, /// Cursor in der Gegnerliste. target: usize, /// `true` = die Gegnerliste ist aktiv. Ein `bool` und Tab statt zweier /// gleichzeitig sichtbarer Cursor — dieselbe Bedienung wie im Laden. targeting: bool, }, /// Zwei Bereiche: Inventar mit Cursor, Ausrüstung als Registry-Schleife. Inventory { pick: usize, }, /// Zugleich der Verteil-Screen — **kein zwoelfter Screen** (Ticket 15). CharacterSheet { /// Cursor in `Attribute::ASSIGNABLE`. pick: usize, /// Vorgemerkte Punkte, noch nicht abgeschickt. Ein festes Feld statt /// eines `Vec`, weil die fuenf verteilbaren Grundwerte eine feste /// Liste sind. pending: [u32; 5], }, /// Vollbild, kein Overlay. Bewusst nicht die lazy Variante mit einer /// Meldungszeile im Hauptmenue: Ein Abstiegs-Roguelike lebt davon, dass der /// Lauf am Ende *erzaehlt* wird (Ticket 14). GameOver, /// Rueckfrage. Ticket 09 wollte `on_yes: Box` und ein implizites /// „Nein = wegklicken" — das reicht nicht: Beim Ladefall heisst Nein /// *beenden und den Mod nachinstallieren*, denn diese Wahl hat der Spieler /// nur **vor** dem Laden. Das symmetrische Paar ist ein Feld mehr und /// spart die ungeschriebene Regel. Confirm { text: String, on_yes: Box, on_no: Box, }, /// Werkbank. Immer mit gebaut statt hinter `cfg(debug_assertions)`: Das /// waere ein `#[cfg]` an der Variante *und* an jedem `match` darueber, fuer /// ein Spiel, das niemand ausliefert. Ein `#[cfg]` reicht spaeter nach. Debug { pick: usize, }, /// **Flache Optionsliste**, kein Knotengraph (M4): Was erst nach einer /// Antwort erscheint, ist eine Option mit einer Bedingung. Dialog { pick: usize, }, /// Zwei Spalten: Angebot links, eigenes Inventar rechts. `Tab` wechselt. Shop { pick: usize, /// `false` = Angebot, `true` = Inventar. Ein `bool` statt eines Enums /// mit zwei Werten, die nie mehr werden. selling: bool, }, // TODO(M5+): `CharacterCreation`, `QuestLog`, `Confirm` — elf sind es am // Ende (11/14). } /// Der Screen **gibt zurueck, statt selbst zu schieben**: sonst haelt er ein /// `&mut` auf genau die `Vec`, in der er liegt. Nebeneffekt — `Response::Command` /// ist der einzige Weg nach `game/`. #[derive(Clone)] pub enum Response { None, Pop, Push(Screen), Command(Command), Quit, } impl Screen { pub fn sheet() -> Screen { Screen::CharacterSheet { pick: 0, pending: [0; 5] } } pub fn is_overlay(&self) -> bool { // TODO(M2): `Confirm` ist das erste echte Overlay. Bis dahin haelt die // Regel „ein Command schliesst alle Overlays" nur die Luft. false } /// **Befund aus M3:** Ticket 11 hatte `handle_key(&mut self, key)` ohne /// Kontext festgelegt. Der Kampf-Screen muss aber wissen, wie viele /// Faehigkeiten es gibt, um den Cursor zu klemmen, und welche der Cursor /// meint. Lesender Zugriff genuegt — die Regel „ein Screen fasst `Game` /// nicht an" bleibt, weil nur `Response::Command` etwas aendert. pub fn handle_key(&mut self, key: KeyCode, game: &Game, reg: &Registries) -> Response { match self { Screen::MainMenu => match key { KeyCode::Enter => Response::Push(Screen::Explore), KeyCode::Char('q') | KeyCode::Esc => Response::Quit, _ => Response::None, }, Screen::Explore => match key { // Pfeiltasten *und* hjkl — drei zusaetzliche Arme sind die // billige Hoeflichkeit gegenueber Vim-Fingern (Ticket 11). KeyCode::Up | KeyCode::Char('k') => { Response::Command(Command::Move(Direction::North)) } KeyCode::Down | KeyCode::Char('j') => { Response::Command(Command::Move(Direction::South)) } KeyCode::Left | KeyCode::Char('h') => { Response::Command(Command::Move(Direction::West)) } KeyCode::Right | KeyCode::Char('l') => { Response::Command(Command::Move(Direction::East)) } KeyCode::Char('.') => Response::Command(Command::Wait), KeyCode::Char('>') => Response::Command(Command::Descend), KeyCode::Char('g') => Response::Command(Command::PickUp), KeyCode::Char('t') => Response::Command(Command::Talk), KeyCode::F(12) => Response::Push(Screen::Debug { pick: 0 }), // Screen-Navigation ist reine UI und fasst `Game` nie an. KeyCode::Char('c') => Response::Push(Screen::sheet()), KeyCode::Char('i') => Response::Push(Screen::Inventory { pick: 0 }), KeyCode::Esc => Response::Pop, _ => Response::None, }, Screen::Combat { pick, target, targeting } => { let abilities = game.abilities_of(CreatureRef::Player, reg); if abilities.is_empty() { return Response::None; } *pick = (*pick).min(abilities.len() - 1); *target = (*target).min(game.enemies.len().saturating_sub(1)); // Der aktive Cursor und seine Laenge — sonst stuende dieselbe // Klemmung viermal da. let (cursor, len) = if *targeting { (&mut *target, game.enemies.len()) } else { (&mut *pick, abilities.len()) }; match key { KeyCode::Tab | KeyCode::BackTab => { *targeting = !*targeting; Response::None } KeyCode::Up | KeyCode::Char('k') => { *cursor = cursor.saturating_sub(1); Response::None } KeyCode::Down | KeyCode::Char('j') => { *cursor = (*cursor + 1).min(len.saturating_sub(1)); Response::None } // Die Ziffern springen aufs Ziel, sie feuern nicht — sonst // haette dieselbe Taste je nach Seite zwei Bedeutungen. KeyCode::Char(c @ '1'..='9') => { let i = (c as u8 - b'1') as usize; if i < game.enemies.len() { *target = i; } Response::None } // Ohne Gegner (Flucht, Selbstheilung) zaehlt der Held // selbst als Ziel. KeyCode::Enter => { let ziel = match game.enemies.is_empty() { true => CreatureRef::Player, false => CreatureRef::Enemy(*target), }; Response::Command(Command::UseAbility { ability: abilities[*pick].clone(), target: ziel, }) } KeyCode::Char('.') => Response::Command(Command::Wait), KeyCode::Char('c') => Response::Push(Screen::sheet()), // Derselbe Screen wie draussen. `rejects` verbietet // `UseItem` im Kampf nicht — es fehlte nur die Tuer. KeyCode::Char('i') => Response::Push(Screen::Inventory { pick: 0 }), KeyCode::F(12) => Response::Push(Screen::Debug { pick: 0 }), _ => Response::None, } } Screen::Inventory { pick } => { let n = game.player.inventory.len(); if n > 0 { *pick = (*pick).min(n - 1); } match key { KeyCode::Up | KeyCode::Char('k') => { *pick = pick.saturating_sub(1); Response::None } KeyCode::Down | KeyCode::Char('j') => { *pick = (*pick + 1).min(n.saturating_sub(1)); Response::None } KeyCode::Enter | KeyCode::Char('u') if n > 0 => { Response::Command(Command::UseItem(*pick)) } KeyCode::Char('e') if n > 0 => Response::Command(Command::Equip(*pick)), KeyCode::Char('d') if n > 0 => Response::Command(Command::Drop(*pick)), // Ablegen ueber die Slot-Reihenfolge aus der Registry — // der Charakterbogen mauert kein Layout (Ticket 02). KeyCode::Char(c @ '1'..='9') => { let index = (c as u8 - b'1') as usize; match reg.slots_ordered().get(index) { Some(slot) => { Response::Command(Command::Unequip(slot.id.clone())) } None => Response::None, } } KeyCode::Esc | KeyCode::Char('i') | KeyCode::Char('q') => Response::Pop, _ => Response::None, } } Screen::CharacterSheet { pick, pending } => { // `saturating_sub`, weil `unspent_points` sich unter dem // offenen Screen aendern kann — ein Aufstieg im selben Zug. let offen = game.unspent_points.saturating_sub(pending.iter().sum::()); match key { KeyCode::Up | KeyCode::Char('k') => { *pick = pick.saturating_sub(1); Response::None } KeyCode::Down | KeyCode::Char('j') => { *pick = (*pick + 1).min(Attribute::ASSIGNABLE.len() - 1); Response::None } KeyCode::Right | KeyCode::Char('+' | 'l') if offen > 0 => { pending[*pick] += 1; Response::None } KeyCode::Left | KeyCode::Char('-' | 'h') if pending[*pick] > 0 => { pending[*pick] -= 1; Response::None } // **Alle** Punkte in einem Kommando und damit in einem Zug // (Ticket 15). KeyCode::Enter => { let punkte = Attribute::ASSIGNABLE .iter() .zip(pending.iter()) .filter(|(_, n)| **n > 0) .map(|(a, n)| (*a, *n)) .collect(); // **Vorgemerktes wird beim Abschicken verbraucht.** Ohne // das behaelt der Screen Punkte, die das Spiel schon // abgezogen hat — und rechnet `0 - 1` auf `u32`. *pending = [0; 5]; Response::Command(Command::AssignPoints(punkte)) } KeyCode::Esc | KeyCode::Char('c' | 'q') => Response::Pop, _ => Response::None, } } Screen::Dialog { pick } => { let options = game.dialog_options(reg); match key { KeyCode::Up | KeyCode::Char('k') => { *pick = pick.saturating_sub(1); Response::None } KeyCode::Down | KeyCode::Char('j') => { *pick = (*pick + 1).min(options.len().saturating_sub(1)); Response::None } KeyCode::Enter | KeyCode::Char('1'..='9') => { let i = match key { KeyCode::Char(c @ '1'..='9') => (c as u8 - b'1') as usize, _ => *pick, }; match options.get(i) { Some(_) => Response::Command(Command::Choose(i)), None => Response::None, } } KeyCode::Esc | KeyCode::Char('q') => Response::Pop, _ => Response::None, } } Screen::Shop { pick, selling } => { let n = if *selling { game.player.inventory.len() } else { game.shop_stock().map_or(0, |s| s.len()) }; if n > 0 { *pick = (*pick).min(n - 1); } match key { KeyCode::Tab | KeyCode::Left | KeyCode::Right => { *selling = !*selling; *pick = 0; Response::None } KeyCode::Up | KeyCode::Char('k') => { *pick = pick.saturating_sub(1); Response::None } KeyCode::Down | KeyCode::Char('j') => { *pick = (*pick + 1).min(n.saturating_sub(1)); Response::None } KeyCode::Enter if n > 0 && *selling => { Response::Command(Command::Sell(*pick)) } KeyCode::Enter if n > 0 => Response::Command(Command::Buy(*pick)), KeyCode::Esc | KeyCode::Char('q') => Response::Pop, _ => Response::None, } } Screen::Debug { pick } => { let items = reg.items_ordered(); if !items.is_empty() { *pick = (*pick).min(items.len() - 1); } match key { KeyCode::Up | KeyCode::Char('k') => { *pick = pick.saturating_sub(1); Response::None } KeyCode::Down | KeyCode::Char('j') => { *pick = (*pick + 1).min(items.len().saturating_sub(1)); Response::None } KeyCode::Enter if !items.is_empty() => { Response::Command(Command::Debug(Cheat::Give(items[*pick].id.clone()))) } KeyCode::Char('h') => Response::Command(Command::Debug(Cheat::Heal)), KeyCode::Char('>') => Response::Command(Command::Debug(Cheat::Descend)), KeyCode::Char('x') => Response::Command(Command::Debug(Cheat::ClearRoom)), KeyCode::Esc | KeyCode::F(12) | KeyCode::Char('q') => Response::Pop, _ => Response::None, } } Screen::Confirm { on_yes, on_no, .. } => match key { KeyCode::Char('j' | 'y') | KeyCode::Enter => (**on_yes).clone(), KeyCode::Char('n' | 'q') | KeyCode::Esc => (**on_no).clone(), _ => Response::None, }, Screen::GameOver => Response::Quit, } } } #[cfg(test)] mod tests { use super::*; use crate::data::load_mods; use crate::game::{Creature, Rng}; fn spiel() -> (Game, Registries) { let (reg, _) = load_mods().expect("core muss laden"); let game = Game::new(Creature::new("Test"), ®, Rng::new(1)).expect("Etage 1"); (game, reg) } /// Gefunden im Spiel: Nach dem Abschicken stand `unspent_points` auf 0, /// waehrend der Screen den Punkt noch vorgemerkt hielt — `0 - 1` auf `u32`. /// Der erste Test, der **Screen-Zustand gegen Spielzustand** prueft; die /// Trennung aus Ticket 11 macht genau diese Naht zur Fehlerquelle. #[test] fn vorgemerkte_punkte_werden_beim_abschicken_verbraucht() { let (mut game, reg) = spiel(); game.unspent_points = 1; let mut screen = Screen::sheet(); screen.handle_key(KeyCode::Char('+'), &game, ®); let Screen::CharacterSheet { pending, .. } = &screen else { panic!("falscher Screen") }; assert_eq!(pending[0], 1, "der Punkt ist vorgemerkt"); let antwort = screen.handle_key(KeyCode::Enter, &game, ®); let Response::Command(cmd) = antwort else { panic!("Enter muss abschicken") }; assert!(matches!(game.execute(cmd, ®), crate::game::Outcome::TurnPassed)); assert_eq!(game.unspent_points, 0); let Screen::CharacterSheet { pending, .. } = &screen else { panic!("falscher Screen") }; assert_eq!(pending[0], 0, "und im Screen nicht mehr vorgemerkt"); // Der Zustand, der abgestuerzt ist: kein offener Punkt, keine Vormerkung. screen.handle_key(KeyCode::Char('+'), &game, ®); let Screen::CharacterSheet { pending, .. } = &screen else { panic!("falscher Screen") }; assert_eq!(pending[0], 0, "ohne offene Punkte laesst sich nichts vormerken"); } }