371 lines
14 KiB
Rust
371 lines
14 KiB
Rust
//! # 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,
|
|
}
|
|
|
|
/// 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::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: `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);
|
|
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;
|
|
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),
|
|
}
|
|
}
|