Compare commits
3 Commits
main
...
prototyp/1
| Author | SHA1 | Date | |
|---|---|---|---|
| c7fd226711 | |||
| 604b8987ba | |||
| 2ecf71af37 |
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"
|
||||
|
||||
373
examples/durchstich.rs
Normal file
373
examples/durchstich.rs
Normal file
@@ -0,0 +1,373 @@
|
||||
//! # 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::{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<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);
|
||||
// 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),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user