feat(kampf): verbindliche Gegner-Ansage und Zielcursor

Am Zugende legt jeder Gegner fest, was er im naechsten Zug tut; der
Kampf-Screen zeigt es unter dem Lebensbalken. Verbindlich statt Vorschau,
denn nur so entsteht ein Fenster: Wer dem Gegner zwischen Ansage und
Ausfuehrung die Ausdauer nimmt, kostet ihn den Zug.

Zielwahl bekommt einen eigenen Cursor, Tab schaltet zwischen Gegner- und
Faehigkeitsliste um — dieselbe Bedienung wie im Laden. Die Ziffern
springen nur noch aufs Ziel, ausgeloest wird mit Enter.
This commit is contained in:
2026-08-12 20:49:24 +02:00
parent 7963df5236
commit 6014471c33
8 changed files with 233 additions and 47 deletions

View File

@@ -33,7 +33,7 @@ impl App {
let shows_combat = self.screens.iter().any(|s| matches!(s, Screen::Combat { .. }));
if in_combat && !shows_combat {
self.screens.push(Screen::Combat { pick: 0 });
self.screens.push(Screen::Combat { pick: 0, target: 0, targeting: false });
} else if !in_combat {
self.screens.retain(|s| !matches!(s, Screen::Combat { .. }));
}
@@ -132,7 +132,9 @@ 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::Combat { pick, target, targeting } => {
draw_combat(frame, area, app, *pick, *target, *targeting)
}
Screen::Inventory { pick } => draw_inventory(frame, area, app, *pick),
Screen::CharacterSheet => draw_sheet(frame, area, app),
Screen::Dialog { pick } => draw_dialog(frame, area, app, *pick),
@@ -345,7 +347,14 @@ fn draw_shop(frame: &mut Frame, area: Rect, app: &App, pick: usize, selling: boo
/// 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) {
fn draw_combat(
frame: &mut Frame,
area: Rect,
app: &App,
pick: usize,
target: usize,
targeting: bool,
) {
let block = Block::bordered().title(" Kampf ");
let inner = block.inner(area);
frame.render_widget(block, area);
@@ -355,7 +364,7 @@ fn draw_combat(frame: &mut Frame, area: Rect, app: &App, pick: usize) {
let [gegner_area, log_area] =
Layout::vertical([Constraint::Percentage(45), Constraint::Min(0)]).areas(links);
// Gegner mit Zielziffer und Lebensbalken.
// Gegner mit Zielziffer, Lebensbalken und **Ansage**.
let mut zeilen = Vec::new();
for (i, e) in app.game.enemies.iter().enumerate() {
let max = app
@@ -364,21 +373,36 @@ fn draw_combat(frame: &mut Frame, area: Rect, app: &App, pick: usize) {
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 gewaehlt = targeting && i == target;
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
)));
zeilen.push(
Line::from(format!(
"{} {} {:<12} {:<10} {:>4.0}/{:<4.0} {marken}",
if gewaehlt { "›" } else { " " },
i + 1,
e.name,
balken,
e.health.max(0.0),
max
))
.style(Style::new().fg(if gewaehlt { Color::Yellow } else { Color::Reset })),
);
// Die Ansage ist verbindlich — wer sie stoert, kostet dem Gegner den
// Zug. Deshalb steht sie so gross wie die Lebensanzeige.
let ansage = match app.game.intent_label(i, &app.registries) {
Some(name) => format!(" ↳ {name} → dich"),
None => String::from(" ↳ zögert"),
};
zeilen.push(Line::from(ansage).style(Style::new().fg(Color::Magenta)));
}
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 ")),
Paragraph::new(zeilen).block(
Block::bordered().title(if targeting { " Gegner · aktiv " } else { " Gegner " }),
),
gegner_area,
);
@@ -400,7 +424,7 @@ fn draw_combat(frame: &mut Frame, area: Rect, app: &App, pick: usize) {
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 zeiger = if !targeting && i == pick { "›" } else { " " };
let text = format!(
"{zeiger} {:<16} {:>3} Ausd. {}",
def.name,
@@ -430,12 +454,15 @@ fn draw_combat(frame: &mut Frame, area: Rect, app: &App, pick: usize) {
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")
Line::from("Tab Seite · ↑↓ wählen · 1-9 Ziel · Enter angreifen · . warten · c Bogen")
.style(Style::new().fg(Color::DarkGray)),
);
frame.render_widget(
Paragraph::new(zeilen).block(Block::bordered().title(" Fähigkeiten ")),
Paragraph::new(zeilen).block(
Block::bordered()
.title(if targeting { " Fähigkeiten " } else { " Fähigkeiten · aktiv " }),
),
rechts,
);
}