//! # PROTOTYP — Wegwerfcode (Ticket 12) //! //! Beantwortet genau eine Frage: **Traegt das Screen-Modell aus Ticket 11?** //! Start mit `cargo run --example durchstich`. //! //! Geprueft werden fuenf Zusagen aus 11 und dem Bericht aus 06: //! //! 1. `Vec` fuer Screens *und* Popups, Overlay per Flag //! 2. Screens geben `Response` zurueck, statt den Stack selbst zu mutieren //! 3. `Response::Command` schliesst Overlays automatisch //! 4. Dungeon-Grid als eigenes `impl Widget` mit direktem Buffer-Zugriff //! 5. `mod game` bleibt ratatui-frei (in dieser Datei sichtbar: kein `use ratatui` darin) //! //! Der Zaehler oben rechts macht den Windows-Fallstrick aus 06 sichtbar: Wenn //! ein Tastendruck zwei Zuege erzeugt, laufen "Events" und "Zuege" auseinander. //! //! Bewusst nicht drin: Kampf, Inventar, Shop, Speichern, Mods, Registries. // ============================================================================ // mod game — KENNT RATATUI NICHT. Das ist die halbe Frage dieses Prototyps. // ============================================================================ mod game { #[derive(Clone, Copy)] pub enum Direction { North, South, West, East, } /// Ticket 11: Kommando der UI ans Spiel. Nicht zu verwechseln mit `Action` /// (dem TOML-Effektvokabular aus Ticket 01), das hier gar nicht vorkommt. pub enum Command { Move(Direction), Wait, } pub struct Game { pub map: Vec<&'static str>, pub player: (u16, u16), pub turn: u64, /// Zaehlt Zuege getrennt von Tasten-Events — siehe Modul-Doku. pub moves_executed: u64, pub last: String, } impl Game { pub fn new() -> Self { // ponytail: feste Karte als &str-Zeilen. Ticket 10 entscheidet das // echte Prefab-Format; hier braucht es nur etwas zum Dagegenlaufen. let map = vec![ "########################", "#......#........#......#", "#......#........#......#", "#..##..#..####..#..##..#", "#..##.....#..#.....##..#", "#......#..#..#..#......#", "####...#..####..#...####", "#......#........#......#", "#..#####........#####..#", "#......................#", "########################", ]; Self { map, player: (2, 1), turn: 0, moves_executed: 0, last: String::from("—"), } } pub fn tile(&self, x: u16, y: u16) -> char { self.map .get(y as usize) .and_then(|row| row.chars().nth(x as usize)) .unwrap_or('#') } /// Ticket 11: der **eine** Eingang, an dem ein Zug endet. pub fn execute(&mut self, cmd: Command) { match cmd { Command::Move(dir) => { let (x, y) = self.player; let (nx, ny) = match dir { Direction::North => (x, y.saturating_sub(1)), Direction::South => (x, y + 1), Direction::West => (x.saturating_sub(1), y), Direction::East => (x + 1, y), }; if self.tile(nx, ny) == '#' { self.last = String::from("Wand"); } else { self.player = (nx, ny); self.last = format!("nach {},{}", nx, ny); } self.moves_executed += 1; } Command::Wait => { self.last = String::from("gewartet"); self.moves_executed += 1; } } // Ticket 01: eine Uhr, der Zug. Hier endet er — genau einmal, // egal welches Kommando. TODO: befristete Modifikatoren ticken // lassen und die Event-Warteschlange aus Ticket 08 leerarbeiten. self.turn += 1; } } } // ============================================================================ // mod ui — ab hier darf ratatui vorkommen // ============================================================================ use game::{Command, Direction, Game}; use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; use ratatui::layout::Rect; use ratatui::style::{Color, Style, Stylize}; use ratatui::text::Line; use ratatui::widgets::{Block, Clear, Paragraph, Widget}; use ratatui::{Frame, buffer::Buffer}; /// Ticket 11: Popups sind gewoehnliche Screens, die sich als Overlay ausweisen. enum Screen { MainMenu, Explore, Confirm { text: String, on_yes: Command }, } /// Ticket 11: Der Screen schiebt nicht selbst — er antwortet, die Schleife fuehrt aus. enum Response { None, Pop, Push(Screen), Command(Command), Quit, } impl Screen { fn is_overlay(&self) -> bool { matches!(self, Screen::Confirm { .. }) } fn handle_key(&mut self, key: KeyCode) -> 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 { 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), // Prueft Zusage 3: Der Confirm traegt den Command mit sich. KeyCode::Char('x') => Response::Push(Screen::Confirm { text: String::from("Einen Zug verstreichen lassen?"), on_yes: Command::Wait, }), KeyCode::Esc => Response::Pop, _ => Response::None, }, Screen::Confirm { on_yes, .. } => match key { KeyCode::Char('j') | KeyCode::Char('y') | KeyCode::Enter => { // `on_yes` herauszunehmen erzwingt ein Ersetzen — Command ist // nicht Copy. Im echten Code wird das ein `Clone`. let cmd = std::mem::replace(on_yes, Command::Wait); Response::Command(cmd) } KeyCode::Char('n') | KeyCode::Esc => Response::Pop, _ => Response::None, }, } } } /// Zusage 4: Es gibt kein Grid-Widget, also Eigenbau mit direktem Buffer-Zugriff. struct GridWidget<'a> { game: &'a Game, } impl Widget for GridWidget<'_> { fn render(self, area: Rect, buf: &mut Buffer) { for (y, row) in self.game.map.iter().enumerate() { for (x, ch) in row.chars().enumerate() { let (px, py) = (area.x + x as u16, area.y + y as u16); if px >= area.right() || py >= area.bottom() { continue; } let (symbol, color) = match ch { '#' => ("█", Color::DarkGray), _ => ("·", Color::Rgb(60, 60, 70)), }; buf[(px, py)].set_symbol(symbol).set_style(Style::new().fg(color)); } } let (px, py) = self.game.player; let (sx, sy) = (area.x + px, area.y + py); if sx < area.right() && sy < area.bottom() { buf[(sx, sy)] .set_symbol("@") .set_style(Style::new().fg(Color::Yellow).bold()); } } } struct App { game: Game, screens: Vec, /// Instrumentierung: Ohne den Press-Filter muesste dieser Zaehler unter /// Windows doppelt so schnell steigen wie `game.moves_executed`. key_events_total: u64, key_events_press: u64, } fn main() -> std::io::Result<()> { let mut app = App { game: Game::new(), screens: vec![Screen::MainMenu], key_events_total: 0, key_events_press: 0, }; ratatui::run(|terminal| { loop { terminal.draw(|frame| draw(frame, &app))?; let Event::Key(key) = event::read()? else { continue; }; app.key_events_total += 1; // Ticket 11 / Bericht 06: Pflichtfilter unter Windows. // Zum Gegentest diese drei Zeilen auskommentieren — dann laufen // die Zaehler in der Statuszeile auseinander. if key.kind != KeyEventKind::Press { continue; } app.key_events_press += 1; let Some(screen) = app.screens.last_mut() else { break Ok(()); }; match screen.handle_key(key.code) { Response::None => {} Response::Pop => { app.screens.pop(); if app.screens.is_empty() { break Ok(()); } } Response::Push(s) => app.screens.push(s), Response::Command(cmd) => { app.game.execute(cmd); // Zusage 3: Ein Command schliesst alle Overlays — eine Zeile, // keine Sonderbehandlung je Screen. while app.screens.last().is_some_and(Screen::is_overlay) { app.screens.pop(); } } Response::Quit => break Ok(()), } } }) } fn draw(frame: &mut Frame, app: &App) { let area = frame.area(); // Zusage 1: von unten nach oben zeichnen, ab dem letzten Vollbild-Screen. let first = app .screens .iter() .rposition(|s| !s.is_overlay()) .unwrap_or(0); for screen in &app.screens[first..] { match screen { Screen::MainMenu => { let text = vec![ Line::from("rustkeep — Durchstich (Ticket 12)".bold()), Line::from(""), Line::from("Enter Dungeon betreten"), Line::from("q beenden"), ]; frame.render_widget( Paragraph::new(text).block(Block::bordered().title(" Hauptmenü ")), area, ); } Screen::Explore => { let block = Block::bordered().title(" Etage 1 "); let inner = block.inner(area); frame.render_widget(block, area); frame.render_widget(GridWidget { game: &app.game }, inner); frame.render_widget(status(app), Rect { height: 1, ..inner }); } Screen::Confirm { text, .. } => { let popup = centered(area, 44, 5); // Bericht 06: kein Widget-Stack, sondern `Clear` auf ein Sub-Rect. frame.render_widget(Clear, popup); frame.render_widget( Paragraph::new(vec![Line::from(text.as_str()), Line::from(""), Line::from("j / n")]) .block(Block::bordered().title(" Rückfrage ")), popup, ); } } } } /// Regel 5 des Prototyp-Skills: Zustand nach jeder Aktion sichtbar machen. fn status(app: &App) -> Paragraph<'static> { let (x, y) = app.game.player; Paragraph::new(format!( "Zug {} │ Züge {} │ Events {} (Press {}) │ Pos {},{} │ Stack {} │ {}", app.game.turn, app.game.moves_executed, app.key_events_total, app.key_events_press, x, y, app.screens.len(), app.game.last, )) .right_aligned() .style(Style::new().fg(Color::Cyan)) } fn centered(area: Rect, w: u16, h: u16) -> Rect { Rect { x: area.x + (area.width.saturating_sub(w)) / 2, y: area.y + (area.height.saturating_sub(h)) / 2, width: w.min(area.width), height: h.min(area.height), } }