prototyp: ratatui-durchstich fuer ticket 12 (wegwerfcode)

This commit is contained in:
2026-08-12 11:10:37 +02:00
parent 2c19cb2aaf
commit 2ecf71af37
3 changed files with 1934 additions and 2 deletions

1589
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,3 +6,8 @@ edition = "2024"
[dependencies] [dependencies]
serde = { version = "1.0.229", features = ["derive"] } serde = { version = "1.0.229", features = ["derive"] }
toml = "1.1.4" toml = "1.1.4"
# Nur fuer examples/durchstich.rs (Ticket 12, Wegwerf-Prototyp).
# Wandert nach [dependencies], sobald der Durchstich als tragfaehig gilt.
[dev-dependencies]
ratatui = "0.30.2"

342
examples/durchstich.rs Normal file
View File

@@ -0,0 +1,342 @@
//! # 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<Screen>` 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<Screen>,
/// 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),
}
}