//! # 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, } /// BEFUND aus dem Durchstich: Ticket 11 sagte "alle Commands kosten einen /// Zug". Richtig ist "alle *ausgefuehrten*". Gegen eine Wand zu laufen darf /// keine Zeit verbrauchen — sonst tickt ein Gift-Debuff, weil jemand /// danebengetippt hat. Der Grund muss mit zurueck, sonst steht der Spieler /// vor einem Spiel, das schweigend nichts tut. pub enum Outcome { TurnPassed, Rejected(&'static str), } 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) -> Outcome { let outcome = 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) == '#' { Outcome::Rejected("Da ist eine Wand") } else { self.player = (nx, ny); self.last = format!("nach {},{}", nx, ny); Outcome::TurnPassed } } Command::Wait => { self.last = String::from("gewartet"); Outcome::TurnPassed } }; // Ticket 01: eine Uhr, der Zug. Hier endet er — aber nur, wenn // wirklich etwas geschehen ist. TODO: befristete Modifikatoren // ticken lassen und die Event-Warteschlange aus Ticket 08 // leerarbeiten — beides gehoert genau hierhin. if let Outcome::TurnPassed = outcome { self.turn += 1; self.moves_executed += 1; } outcome } } } // ============================================================================ // mod ui — ab hier darf ratatui vorkommen // ============================================================================ use game::{Command, Direction, Game, Outcome}; use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; use ratatui::layout::{Constraint, 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: `total` zaehlt jedes `Event::Key`, `press` nur die /// gefilterten. Unter Windows laufen die beiden auseinander — genau das /// belegt, dass der Filter aus Ticket 11 kein Aberglaube ist. key_events_total: u64, key_events_press: u64, /// Grund des letzten abgewiesenen Kommandos, sonst `None`. rejected: Option<&'static str>, } fn main() -> std::io::Result<()> { let mut app = App { game: Game::new(), screens: vec![Screen::MainMenu], key_events_total: 0, key_events_press: 0, rejected: None, }; 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.rejected = match app.game.execute(cmd) { Outcome::TurnPassed => None, Outcome::Rejected(reason) => Some(reason), }; // Zusage 3: Ein Command schliesst alle Overlays — eine Zeile, // keine Sonderbehandlung je Screen. Auch bei Rejected: die // Rueckfrage *wurde* beantwortet, sie darf nicht kleben // bleiben, nur weil das Kommando ins Leere lief. 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); // Statuszeile unter das Grid, sonst liegt sie auf der Nordwand. let [grid, bar] = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(inner); frame.render_widget(GridWidget { game: &app.game }, grid); frame.render_widget(status(app), bar); } 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; let (note, color) = match app.rejected { Some(reason) => (reason.to_string(), Color::Red), None => (app.game.last.clone(), Color::Cyan), }; Paragraph::new(format!( "Zug {} │ Pos {},{} │ Stack {} │ Tasten {} → Press {} → Züge {} │ {}", app.game.turn, x, y, app.screens.len(), app.key_events_total, app.key_events_press, app.game.moves_executed, note, )) .right_aligned() .style(Style::new().fg(color)) } 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), } }