Ersetzt src/ vollstaendig: print_box, include_str! und enum ItemType fallen, wie Ticket 01-03 es vorgezeichnet haben. - data/: Mod-Lader mit zwei Suchpfaden, selbstheilender load_order.toml, drei Phasen und zweistufiger Deserialisierung (03/04) - game/: Attributkatalog, Creature, Game::attribute ueber mehrere Quellen, Command mit Outcome (01/07/11/12) - ui/: Screen-Stack mit Response, Grid-Widget, Press-Filter (06/11/12) - mods/core/: echte TOML-Dateien auf der Platte statt include_str! 10 Tests, darunter der Borrow-Beweis fuer Registries-neben-Game, den Ticket 12 nicht fuehren konnte.
206 lines
7.1 KiB
Rust
206 lines
7.1 KiB
Rust
//! Die Schleife. Blockierendes `event::read()`, kein Tick (Ticket 06).
|
|
|
|
use ratatui::Frame;
|
|
use ratatui::crossterm::event::{self, Event, KeyEventKind};
|
|
use ratatui::layout::{Constraint, Layout, Rect};
|
|
use ratatui::style::{Color, Style, Stylize};
|
|
use ratatui::text::Line;
|
|
use ratatui::widgets::{Block, Paragraph, Row, Table};
|
|
|
|
use super::grid::GridWidget;
|
|
use super::screen::{Response, Screen};
|
|
use super::theme;
|
|
use crate::data::Registries;
|
|
use crate::game::{Attribute, CreatureRef, Game, Outcome};
|
|
|
|
pub struct App {
|
|
/// `game` und `registries` sind **getrennte Felder**, damit die disjunkten
|
|
/// Borrows aus Ticket 07 funktionieren.
|
|
pub game: Game,
|
|
pub registries: Registries,
|
|
screens: Vec<Screen>,
|
|
warnings: usize,
|
|
log_name: Option<String>,
|
|
rejected: Option<&'static str>,
|
|
}
|
|
|
|
pub fn run(game: Game, registries: Registries, warnings: usize, log_name: Option<String>) -> std::io::Result<()> {
|
|
let mut app = App {
|
|
game,
|
|
registries,
|
|
screens: vec![Screen::MainMenu],
|
|
warnings,
|
|
log_name,
|
|
rejected: None,
|
|
};
|
|
|
|
ratatui::run(|terminal| {
|
|
loop {
|
|
terminal.draw(|frame| draw(frame, &app))?;
|
|
|
|
let Event::Key(key) = event::read()? else { continue };
|
|
// Pflichtfilter unter Windows: Ticket 12 hat gemessen, dass jeder
|
|
// Tastendruck zwei Events erzeugt (Press und Release).
|
|
if key.kind != KeyEventKind::Press {
|
|
continue;
|
|
}
|
|
|
|
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) => {
|
|
// Hier steht die Zusage aus Ticket 07 auf dem Pruefstand:
|
|
// `&mut app.game` neben `&app.registries` in einem Aufruf.
|
|
app.rejected = match app.game.execute(cmd, &app.registries) {
|
|
Outcome::TurnPassed => None,
|
|
Outcome::Rejected(reason) => Some(reason),
|
|
};
|
|
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();
|
|
let first = app.screens.iter().rposition(|s| !s.is_overlay()).unwrap_or(0);
|
|
|
|
for screen in &app.screens[first..] {
|
|
match screen {
|
|
Screen::MainMenu => draw_main_menu(frame, area, app),
|
|
Screen::Explore => draw_explore(frame, area, app),
|
|
Screen::CharacterSheet => draw_sheet(frame, area, app),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn draw_main_menu(frame: &mut Frame, area: Rect, app: &App) {
|
|
let mut text = vec![
|
|
Line::from("rustkeep".bold()),
|
|
Line::from(""),
|
|
Line::from("Enter Dungeon betreten"),
|
|
Line::from("q beenden"),
|
|
];
|
|
|
|
// Eine Zeile, kein Screen — der Inhalt gehoert in einen Texteditor
|
|
// (Ticket 04).
|
|
if app.warnings > 0 {
|
|
let note = match &app.log_name {
|
|
Some(file) => format!("{} Ladewarnungen — logs/{file}", app.warnings),
|
|
None => format!("{} Ladewarnungen (Log nicht schreibbar)", app.warnings),
|
|
};
|
|
text.push(Line::from(""));
|
|
text.push(Line::from(note).style(Style::new().fg(Color::Yellow)));
|
|
}
|
|
|
|
frame.render_widget(
|
|
Paragraph::new(text).block(Block::bordered().title(" Hauptmenü ")),
|
|
area,
|
|
);
|
|
}
|
|
|
|
fn draw_explore(frame: &mut Frame, area: Rect, app: &App) {
|
|
let block = Block::bordered().title(format!(" Etage {} ", app.game.floor.number));
|
|
let inner = block.inner(area);
|
|
frame.render_widget(block, area);
|
|
|
|
let [grid, bar] = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(inner);
|
|
frame.render_widget(
|
|
GridWidget { floor: &app.game.floor, player: app.game.player_pos },
|
|
grid,
|
|
);
|
|
|
|
let (note, color) = match app.rejected {
|
|
Some(reason) => (reason.to_string(), Color::Red),
|
|
None => (app.game.last.clone(), Color::Cyan),
|
|
};
|
|
frame.render_widget(
|
|
Paragraph::new(format!(
|
|
"Zug {} │ {} │ c Charakterbogen │ {}",
|
|
app.game.turn, app.game.player.name, note
|
|
))
|
|
.right_aligned()
|
|
.style(Style::new().fg(color)),
|
|
bar,
|
|
);
|
|
}
|
|
|
|
/// Der Charakterbogen ist eine **Schleife ueber Registries**, kein gemauertes
|
|
/// Layout — Modder koennen Slots und Wahlkategorien ergaenzen (Ticket 02).
|
|
fn draw_sheet(frame: &mut Frame, area: Rect, app: &App) {
|
|
let block = Block::bordered().title(" Charakterbogen ");
|
|
let inner = block.inner(area);
|
|
frame.render_widget(block, area);
|
|
|
|
let [left, right] = Layout::horizontal([Constraint::Percentage(50); 2]).areas(inner);
|
|
|
|
// Attribute: jeder Wert kommt aus Game::attribute, laeuft also durch alle
|
|
// Quellen — das ist der Punkt des ganzen Durchstichs.
|
|
let rows: Vec<Row> = Attribute::ALL
|
|
.iter()
|
|
.map(|a| {
|
|
let base = app.game.player.base.get(*a);
|
|
let total =
|
|
app.game
|
|
.attribute(CreatureRef::Player, *a, None, &app.registries);
|
|
let cell = if (total - base).abs() < f32::EPSILON {
|
|
format!("{total:.0}")
|
|
} else {
|
|
format!("{total:.0} ({base:.0} Basis)")
|
|
};
|
|
Row::new(vec![a.name().to_string(), cell])
|
|
})
|
|
.collect();
|
|
|
|
frame.render_widget(
|
|
Table::new(rows, [Constraint::Length(20), Constraint::Min(0)])
|
|
.block(Block::bordered().title(" Attribute ")),
|
|
left,
|
|
);
|
|
|
|
let mut lines = Vec::new();
|
|
|
|
lines.push(Line::from("Herkunft".bold()));
|
|
for id in &app.game.player.options {
|
|
let name = app.registries.options.get(id).map(|o| o.name.as_str()).unwrap_or(id);
|
|
lines.push(Line::from(format!(" {name}")));
|
|
}
|
|
|
|
lines.push(Line::from(""));
|
|
lines.push(Line::from("Ausrüstung".bold()));
|
|
for slot in app.registries.slots_ordered() {
|
|
let worn = app.game.player.equipment.get(&slot.id).and_then(|inst| {
|
|
app.registries.items.get(&inst.def).map(|i| (i.name.as_str(), i.color.as_deref()))
|
|
});
|
|
match worn {
|
|
Some((name, color)) => lines.push(Line::from(vec![
|
|
format!(" {:<10} ", slot.name).into(),
|
|
name.to_string().fg(theme::color(color)),
|
|
])),
|
|
None => lines.push(
|
|
Line::from(format!(" {:<10} —", slot.name)).style(Style::new().fg(Color::DarkGray)),
|
|
),
|
|
}
|
|
}
|
|
|
|
lines.push(Line::from(""));
|
|
lines.push(Line::from("Esc zurück").style(Style::new().fg(Color::DarkGray)));
|
|
|
|
frame.render_widget(
|
|
Paragraph::new(lines).block(Block::bordered().title(" Herkunft & Ausrüstung ")),
|
|
right,
|
|
);
|
|
}
|