prototyp: ratatui-durchstich fuer ticket 12 (wegwerfcode)
This commit is contained in:
1589
Cargo.lock
generated
1589
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -6,3 +6,8 @@ edition = "2024"
|
||||
[dependencies]
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
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
342
examples/durchstich.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user