feat: m3 kampf, events und trigger
Der Dungeon hat jetzt Gegner, und man kann sterben.
- game/action.rs: sieben Schadenstypen, fuenf der zehn Aktionen, Target
- game/condition.rs: Bedingungsgrammatik (Liste = UND, not je Eintrag)
- game/event.rs: zwoelf Events, Trigger als vierte Wirkungsform
- game/combat.rs: Treffer- und Schadensformel mit drei Klemmbereichen
- Game::apply, drain_events (FIFO), Initiative, Rundenablauf aus Ticket 14
- data/: abilities-, creatures- und modifiers-Registry, [balance] feldweise
- ui/: Combat-Screen mit Faehigkeitsmenue und Sperrgrund
Drei Befunde:
- Die Trefferformel war toter Code — jeder Angriff traf. Nur der Compiler
hat es gemeldet ('hit_chance is never used'). Test haelt sie am Leben.
- Die Warteschlange war ein Stack (pop statt pop_front), obwohl Ticket 08
ausdruecklich 'haengt es hinten an' sagt. Jetzt VecDeque.
- Benannte Modifikatoren (core:poisoned) hatten keine Registry — Ticket 01
laesst apply_modifier per ID referenzieren, aber keine der zwoelf
definierte sie. Das ist die dreizehnte.
43 Tests.
This commit is contained in:
162
src/ui/app.rs
162
src/ui/app.rs
@@ -11,7 +11,7 @@ use super::grid::GridWidget;
|
||||
use super::screen::{Response, Screen};
|
||||
use super::theme;
|
||||
use crate::data::Registries;
|
||||
use crate::game::{Attribute, CreatureRef, Game, Outcome, RunOutcome};
|
||||
use crate::game::{Attribute, CreatureRef, DamageType, Game, Outcome, RunOutcome};
|
||||
|
||||
pub struct App {
|
||||
/// `game` und `registries` sind **getrennte Felder**, damit die disjunkten
|
||||
@@ -24,6 +24,28 @@ pub struct App {
|
||||
rejected: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// Der Spielzustand fuehrt, der Stack folgt: `Game.enemies` und `Game.over`
|
||||
/// sind die Wahrheit. Ohne das muesste jede Stelle, die einen Kampf startet
|
||||
/// oder beendet, daran denken, den Screen mitzuschieben.
|
||||
fn sync_screens(&mut self) {
|
||||
let in_combat = !self.game.enemies.is_empty();
|
||||
let shows_combat = self.screens.iter().any(|s| matches!(s, Screen::Combat { .. }));
|
||||
|
||||
if in_combat && !shows_combat {
|
||||
self.screens.push(Screen::Combat { pick: 0 });
|
||||
} else if !in_combat {
|
||||
self.screens.retain(|s| !matches!(s, Screen::Combat { .. }));
|
||||
}
|
||||
|
||||
if self.game.over.is_some()
|
||||
&& !self.screens.iter().any(|s| matches!(s, Screen::GameOver))
|
||||
{
|
||||
self.screens.push(Screen::GameOver);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(game: Game, registries: Registries, warnings: usize, log_name: Option<String>) -> std::io::Result<()> {
|
||||
let mut app = App {
|
||||
game,
|
||||
@@ -45,9 +67,12 @@ pub fn run(game: Game, registries: Registries, warnings: usize, log_name: Option
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(screen) = app.screens.last_mut() else { break Ok(()) };
|
||||
// Die Screens liegen in `app.screens`, gelesen wird aus `app.game`
|
||||
// und `app.registries` — drei getrennte Felder, disjunkte Borrows.
|
||||
let App { game, registries, screens, .. } = &mut app;
|
||||
let Some(screen) = screens.last_mut() else { break Ok(()) };
|
||||
|
||||
match screen.handle_key(key.code) {
|
||||
match screen.handle_key(key.code, game, registries) {
|
||||
Response::None => {}
|
||||
Response::Pop => {
|
||||
app.screens.pop();
|
||||
@@ -67,10 +92,9 @@ pub fn run(game: Game, registries: Registries, warnings: usize, log_name: Option
|
||||
app.screens.pop();
|
||||
}
|
||||
// Ticket 10/14: Der Lauf endet ueber `Game`, nicht ueber
|
||||
// die UI — die reagiert nur darauf.
|
||||
if app.game.over.is_some() {
|
||||
app.screens.push(Screen::GameOver);
|
||||
}
|
||||
// die UI — die reagiert nur darauf. Dasselbe gilt fuer den
|
||||
// Kampf: `Game.enemies` ist die Wahrheit, der Screen folgt.
|
||||
app.sync_screens();
|
||||
}
|
||||
Response::Quit => break Ok(()),
|
||||
}
|
||||
@@ -86,6 +110,7 @@ fn draw(frame: &mut Frame, app: &App) {
|
||||
match screen {
|
||||
Screen::MainMenu => draw_main_menu(frame, area, app),
|
||||
Screen::Explore => draw_explore(frame, area, app),
|
||||
Screen::Combat { pick } => draw_combat(frame, area, app, *pick),
|
||||
Screen::CharacterSheet => draw_sheet(frame, area, app),
|
||||
Screen::GameOver => draw_game_over(frame, area, app),
|
||||
}
|
||||
@@ -143,6 +168,104 @@ fn draw_explore(frame: &mut Frame, area: Rect, app: &App) {
|
||||
);
|
||||
}
|
||||
|
||||
/// Menue-Kampf (Charter: Dungeon Master / Etrian Odyssey). Links die Gegner mit
|
||||
/// Ziffern, rechts die Faehigkeiten mit Kosten und **Sperrgrund** — der kommt
|
||||
/// aus dem Spielkern, nicht aus der UI (Ticket 14).
|
||||
fn draw_combat(frame: &mut Frame, area: Rect, app: &App, pick: usize) {
|
||||
let block = Block::bordered().title(" Kampf ");
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let [links, rechts] = Layout::horizontal([Constraint::Percentage(45), Constraint::Min(0)])
|
||||
.areas(inner);
|
||||
let [gegner_area, log_area] =
|
||||
Layout::vertical([Constraint::Percentage(45), Constraint::Min(0)]).areas(links);
|
||||
|
||||
// Gegner mit Zielziffer und Lebensbalken.
|
||||
let mut zeilen = Vec::new();
|
||||
for (i, e) in app.game.enemies.iter().enumerate() {
|
||||
let max = app
|
||||
.game
|
||||
.attribute(CreatureRef::Enemy(i), Attribute::MaxHealth, None, &app.registries);
|
||||
let anteil = if max > 0.0 { (e.health / max).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let balken = "█".repeat((anteil * 10.0).round() as usize);
|
||||
|
||||
let marken: String = e.modifiers.iter().map(|m| m.symbol).collect();
|
||||
zeilen.push(Line::from(format!(
|
||||
"{} {:<14} {:<10} {:>4.0}/{:<4.0} {marken}",
|
||||
i + 1,
|
||||
e.name,
|
||||
balken,
|
||||
e.health.max(0.0),
|
||||
max
|
||||
)));
|
||||
}
|
||||
if zeilen.is_empty() {
|
||||
zeilen.push(Line::from("—").style(Style::new().fg(Color::DarkGray)));
|
||||
}
|
||||
frame.render_widget(
|
||||
Paragraph::new(zeilen).block(Block::bordered().title(" Gegner ")),
|
||||
gegner_area,
|
||||
);
|
||||
|
||||
// Kampflog, jüngste Zeile unten.
|
||||
let log: Vec<Line> = app
|
||||
.game
|
||||
.journal
|
||||
.iter()
|
||||
.rev()
|
||||
.take(log_area.height.saturating_sub(2) as usize)
|
||||
.rev()
|
||||
.map(|l| Line::from(l.as_str()))
|
||||
.collect();
|
||||
frame.render_widget(Paragraph::new(log).block(Block::bordered().title(" Verlauf ")), log_area);
|
||||
|
||||
// Faehigkeiten.
|
||||
let mut zeilen = Vec::new();
|
||||
for (i, id) in app.game.abilities_of(CreatureRef::Player, &app.registries).iter().enumerate() {
|
||||
let Some(def) = app.registries.abilities.get(id) else { continue };
|
||||
let block = app.game.ability_block(CreatureRef::Player, id, &app.registries);
|
||||
|
||||
let zeiger = if i == pick { "›" } else { " " };
|
||||
let text = format!(
|
||||
"{zeiger} {:<16} {:>3} Ausd. {}",
|
||||
def.name,
|
||||
def.cost as u32,
|
||||
block.unwrap_or("")
|
||||
);
|
||||
let farbe = match (block.is_some(), i == pick) {
|
||||
(true, _) => Color::DarkGray,
|
||||
(false, true) => Color::Yellow,
|
||||
(false, false) => Color::Reset,
|
||||
};
|
||||
zeilen.push(Line::from(text).style(Style::new().fg(farbe)));
|
||||
}
|
||||
|
||||
let p = &app.game.player;
|
||||
let max_health =
|
||||
app.game.attribute(CreatureRef::Player, Attribute::MaxHealth, None, &app.registries);
|
||||
let max_stamina =
|
||||
app.game.attribute(CreatureRef::Player, Attribute::MaxStamina, None, &app.registries);
|
||||
zeilen.push(Line::from(""));
|
||||
zeilen.push(Line::from(format!(
|
||||
"{} {:.0}/{:.0} Leben {:.0}/{:.0} Ausdauer",
|
||||
p.name, p.health.max(0.0), max_health, p.stamina, max_stamina
|
||||
)));
|
||||
if !p.modifiers.is_empty() {
|
||||
let namen: Vec<&str> = p.modifiers.iter().map(|m| m.name.as_str()).collect();
|
||||
zeilen.push(Line::from(namen.join(", ")).style(Style::new().fg(Color::Green)));
|
||||
}
|
||||
zeilen.push(
|
||||
Line::from("↑↓ wählen · 1-9 Ziel · Enter erstes Ziel · . warten · c Bogen")
|
||||
.style(Style::new().fg(Color::DarkGray)),
|
||||
);
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(zeilen).block(Block::bordered().title(" Fähigkeiten ")),
|
||||
rechts,
|
||||
);
|
||||
}
|
||||
|
||||
/// Der Lauf wird am Ende **erzaehlt** — das ist der Moment, fuer den man die
|
||||
/// Etagen gespielt hat, und der Grund, warum Ticket 14 hier keinen Einzeiler im
|
||||
/// Hauptmenue wollte.
|
||||
@@ -186,8 +309,11 @@ fn draw_sheet(frame: &mut Frame, area: Rect, app: &App) {
|
||||
|
||||
// Attribute: jeder Wert kommt aus Game::attribute, laeuft also durch alle
|
||||
// Quellen — das ist der Punkt des ganzen Durchstichs.
|
||||
// `resistance` faellt heraus: Ein einzelner Wert dafuer existiert nicht, es
|
||||
// sind sieben (Ticket 01). Sie stehen rechts als eigene Zeile.
|
||||
let rows: Vec<Row> = Attribute::ALL
|
||||
.iter()
|
||||
.filter(|a| **a != Attribute::Resistance)
|
||||
.map(|a| {
|
||||
let base = app.game.player.base.get(*a);
|
||||
let total =
|
||||
@@ -233,6 +359,28 @@ fn draw_sheet(frame: &mut Frame, area: Rect, app: &App) {
|
||||
}
|
||||
}
|
||||
|
||||
// Resistenz ist typ-indiziert — nur was von null abweicht, ist erwaehnenswert.
|
||||
let widerstaende: Vec<String> = DamageType::ALL
|
||||
.iter()
|
||||
.filter_map(|dt| {
|
||||
let r = app.game.resistance(CreatureRef::Player, *dt, &app.registries);
|
||||
(r.abs() > f32::EPSILON).then(|| format!("{} {:+.0}%", dt.name(), r * 100.0))
|
||||
})
|
||||
.collect();
|
||||
if !widerstaende.is_empty() {
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from("Resistenz".bold()));
|
||||
lines.push(Line::from(format!(" {}", widerstaende.join(", "))));
|
||||
}
|
||||
|
||||
if !app.game.player.modifiers.is_empty() {
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from("Zustände".bold()));
|
||||
for m in &app.game.player.modifiers {
|
||||
lines.push(Line::from(format!(" {} ({} Züge)", m.name, m.turns_left)));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from("Esc zurück").style(Style::new().fg(Color::DarkGray)));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user