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:
2026-08-12 17:03:00 +02:00
parent 8bcd53efa2
commit 217a7acbbc
20 changed files with 1990 additions and 75 deletions

View File

@@ -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)));