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:
@@ -80,6 +80,11 @@ pub struct Creature {
|
||||
/// mit demselben Ungueltigkeitsproblem beim Tod eines Gegners (Ticket 14).
|
||||
pub initiative: i32,
|
||||
pub cooldowns: HashMap<String, u32>,
|
||||
/// **Die Ansage**: Was dieses Wesen im naechsten Zug tun wird. Am Zugende
|
||||
/// festgelegt und damit verbindlich — nur so ist sie *stoerbar*. Eine
|
||||
/// Vorschau, die sich mit der Lage aendert, waere keine Information,
|
||||
/// sondern eine Vermutung, und darauf laesst sich keine Taktik bauen.
|
||||
pub intent: Option<String>,
|
||||
/// Aus welcher `creatures`-Definition dieses Wesen stammt — der Held hat
|
||||
/// keine. Traegt `ai`-Liste, Faehigkeiten und Trigger.
|
||||
pub def: Option<String>,
|
||||
@@ -102,6 +107,7 @@ impl Creature {
|
||||
modifiers: Vec::new(),
|
||||
initiative: 0,
|
||||
cooldowns: HashMap::new(),
|
||||
intent: None,
|
||||
def: None,
|
||||
abilities: Vec::new(),
|
||||
}
|
||||
|
||||
@@ -523,7 +523,13 @@ impl Game {
|
||||
self.drain_events(reg);
|
||||
}
|
||||
|
||||
// 6.
|
||||
// 6. Ansagen fuer den naechsten Zug. **Nach** dem Entfernen der Toten,
|
||||
// sonst sagt eine Leiche an.
|
||||
for i in 0..self.enemies.len() {
|
||||
self.choose_intent(i, reg);
|
||||
}
|
||||
|
||||
// 7.
|
||||
self.turn += 1;
|
||||
Outcome::TurnPassed
|
||||
}
|
||||
@@ -924,6 +930,13 @@ impl Game {
|
||||
self.count_of("core:gold")
|
||||
}
|
||||
|
||||
/// Was der Gegner angesagt hat, als lesbarer Name. `None` heisst: Er hat
|
||||
/// nichts gefunden, was er tun koennte — der Screen zeigt „zoegert".
|
||||
pub fn intent_label(&self, i: usize, reg: &Registries) -> Option<String> {
|
||||
let id = self.enemies.get(i)?.intent.as_ref()?;
|
||||
Some(reg.abilities.get(id).map_or_else(|| id.clone(), |a| a.name.clone()))
|
||||
}
|
||||
|
||||
/// Der Anzeigename eines Exemplars: Affixe werden vorangestellt —
|
||||
/// „Flammendes Eisenschwert" (Ticket 07).
|
||||
pub fn item_label(&self, inst: &ItemInstance, reg: &Registries) -> String {
|
||||
@@ -971,28 +984,40 @@ impl Game {
|
||||
}
|
||||
}
|
||||
|
||||
/// Erster passender **und verfuegbarer** Eintrag der `ai`-Liste gewinnt.
|
||||
/// Passt nichts, setzt der Gegner aus — einer, der die Runde blockiert,
|
||||
/// waere schlimmer als einer, der nichts tut (Ticket 14).
|
||||
/// Legt fest, was ein Gegner im **naechsten** Zug tut. Erster passender
|
||||
/// **und verfuegbarer** Eintrag der `ai`-Liste gewinnt (Ticket 14) — nur
|
||||
/// eine Runde frueher ausgewertet als bisher. Passt nichts, setzt der
|
||||
/// Gegner aus; einer, der die Runde blockiert, waere schlimmer.
|
||||
fn choose_intent(&mut self, i: usize, reg: &Registries) {
|
||||
let me = CreatureRef::Enemy(i);
|
||||
let Some(def) = self.creature(me).def.clone().and_then(|id| reg.creatures.get(&id)) else {
|
||||
return;
|
||||
};
|
||||
let choice = def.ai.iter().find(|entry| {
|
||||
self.conditions_hold(&entry.condition, me, Some(CreatureRef::Player), reg)
|
||||
&& self.ability_block(me, &entry.ability, reg).is_none()
|
||||
});
|
||||
self.enemies[i].intent = choice.map(|e| e.ability.clone());
|
||||
}
|
||||
|
||||
fn enemy_acts(&mut self, i: usize, reg: &Registries) {
|
||||
let me = CreatureRef::Enemy(i);
|
||||
if !self.creature(me).is_alive() {
|
||||
return;
|
||||
}
|
||||
// Ohne Ansage kein Zug. Wer mitten im Zug auftaucht, steht ohnehin
|
||||
// nicht in der Initiativreihenfolge — er sagt am Zugende erstmals an.
|
||||
let Some(ability) = self.enemies[i].intent.take() else { return };
|
||||
|
||||
let Some(def) = self.creature(me).def.clone().and_then(|id| reg.creatures.get(&id)) else {
|
||||
// Die Ansage ist verbindlich — und genau deshalb **stoerbar**: Wer dem
|
||||
// Gegner zwischen Ansage und Ausfuehrung die Ausdauer nimmt oder die
|
||||
// Bedingung kippt, kostet ihn den Zug. Das ist der taktische Griff.
|
||||
if let Some(grund) = self.ability_block(me, &ability, reg) {
|
||||
let name = self.creature(me).name.clone();
|
||||
self.log(format!("{name} kommt nicht dazu — {grund}."));
|
||||
return;
|
||||
};
|
||||
|
||||
let choice = def.ai.iter().find(|entry| {
|
||||
self.conditions_hold(&entry.condition, me, Some(CreatureRef::Player), reg)
|
||||
&& self.ability_block(me, &entry.ability, reg).is_none()
|
||||
});
|
||||
|
||||
if let Some(entry) = choice {
|
||||
let ability = entry.ability.clone();
|
||||
self.use_ability(me, &ability, CreatureRef::Player, reg);
|
||||
}
|
||||
self.use_ability(me, &ability, CreatureRef::Player, reg);
|
||||
}
|
||||
|
||||
fn step_target(&self, dir: Direction) -> (u16, u16) {
|
||||
@@ -1595,4 +1620,39 @@ mod tests {
|
||||
}
|
||||
assert!(game.enemies.is_empty(), "in 30 Zuegen muss eine Flucht geglueckt sein");
|
||||
}
|
||||
|
||||
/// Die Ansage ist verbindlich — und genau deshalb stoerbar. Wer dem Gegner
|
||||
/// zwischen Ansage und Ausfuehrung die Ausdauer nimmt, kostet ihn den Zug.
|
||||
/// Ohne die Verbindlichkeit gaebe es dieses Fenster nicht: Eine Vorschau
|
||||
/// wuerde einfach auf die naechstbeste Faehigkeit umspringen.
|
||||
#[test]
|
||||
fn angesagte_faehigkeit_laesst_sich_stoeren() {
|
||||
let (mut game, reg) = zwergenkrieger();
|
||||
|
||||
let lord = reg.creatures.get("core:bone_lord").expect("Knochenfuerst");
|
||||
let mut e = Creature::from_def(lord);
|
||||
e.base.set(Attribute::MaxStamina, 100.0);
|
||||
e.stamina = 100.0;
|
||||
game.enemies = vec![e];
|
||||
|
||||
// Erster Zug: Der Fuerst steht noch ohne Ansage da und handelt nicht,
|
||||
// sagt am Zugende aber seinen teuersten Schlag an.
|
||||
game.execute(Command::Wait, ®);
|
||||
assert_eq!(game.enemies[0].intent.as_deref(), Some("core:power_strike"));
|
||||
assert_eq!(game.intent_label(0, ®).as_deref(), Some("Wuchtschlag"));
|
||||
|
||||
game.enemies[0].stamina = 0.0;
|
||||
let vorher = game.player.health;
|
||||
|
||||
// Die Regeneration von 4 reicht fuer die angesagten 15 nicht.
|
||||
game.execute(Command::Wait, ®);
|
||||
|
||||
assert!(
|
||||
game.journal.iter().any(|z| z.contains("kommt nicht dazu")),
|
||||
"die gestoerte Ansage muss im Verlauf stehen: {:?}",
|
||||
game.journal
|
||||
);
|
||||
assert_eq!(game.player.health, vorher, "ein gestoerter Gegner richtet nichts an");
|
||||
assert!(game.enemies[0].intent.is_some(), "und er sagt fuer den naechsten Zug neu an");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,10 +11,15 @@ use crate::game::{Command, CreatureRef, Direction, Game};
|
||||
pub enum Screen {
|
||||
MainMenu,
|
||||
Explore,
|
||||
/// Menue-Kampf (Charter): Faehigkeitsliste, Zielwahl mit den Zifferntasten.
|
||||
/// Menue-Kampf (Charter): Faehigkeitsliste links, Gegnerliste rechts.
|
||||
Combat {
|
||||
/// Cursor in der Faehigkeitsliste.
|
||||
pick: usize,
|
||||
/// Cursor in der Gegnerliste.
|
||||
target: usize,
|
||||
/// `true` = die Gegnerliste ist aktiv. Ein `bool` und Tab statt zweier
|
||||
/// gleichzeitig sichtbarer Cursor — dieselbe Bedienung wie im Laden.
|
||||
targeting: bool,
|
||||
},
|
||||
/// Zwei Bereiche: Inventar mit Cursor, Ausrüstung als Registry-Schleife.
|
||||
Inventory {
|
||||
@@ -98,37 +103,54 @@ impl Screen {
|
||||
_ => Response::None,
|
||||
},
|
||||
|
||||
Screen::Combat { pick } => {
|
||||
Screen::Combat { pick, target, targeting } => {
|
||||
let abilities = game.abilities_of(CreatureRef::Player, reg);
|
||||
if abilities.is_empty() {
|
||||
return Response::None;
|
||||
}
|
||||
*pick = (*pick).min(abilities.len() - 1);
|
||||
*target = (*target).min(game.enemies.len().saturating_sub(1));
|
||||
|
||||
// Der aktive Cursor und seine Laenge — sonst stuende dieselbe
|
||||
// Klemmung viermal da.
|
||||
let (cursor, len) = if *targeting {
|
||||
(&mut *target, game.enemies.len())
|
||||
} else {
|
||||
(&mut *pick, abilities.len())
|
||||
};
|
||||
|
||||
match key {
|
||||
KeyCode::Tab | KeyCode::BackTab => {
|
||||
*targeting = !*targeting;
|
||||
Response::None
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
*pick = pick.saturating_sub(1);
|
||||
*cursor = cursor.saturating_sub(1);
|
||||
Response::None
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
*pick = (*pick + 1).min(abilities.len() - 1);
|
||||
*cursor = (*cursor + 1).min(len.saturating_sub(1));
|
||||
Response::None
|
||||
}
|
||||
// Ziel ueber die Zifferntasten; ohne Gegner (Flucht,
|
||||
// Selbstheilung) zaehlt der Held selbst als Ziel.
|
||||
KeyCode::Enter | KeyCode::Char('1'..='9') => {
|
||||
let index = match key {
|
||||
KeyCode::Char(c @ '1'..='9') => (c as u8 - b'1') as usize,
|
||||
_ => 0,
|
||||
};
|
||||
let target = if game.enemies.is_empty() {
|
||||
CreatureRef::Player
|
||||
} else {
|
||||
CreatureRef::Enemy(index.min(game.enemies.len() - 1))
|
||||
// Die Ziffern springen aufs Ziel, sie feuern nicht — sonst
|
||||
// haette dieselbe Taste je nach Seite zwei Bedeutungen.
|
||||
KeyCode::Char(c @ '1'..='9') => {
|
||||
let i = (c as u8 - b'1') as usize;
|
||||
if i < game.enemies.len() {
|
||||
*target = i;
|
||||
}
|
||||
Response::None
|
||||
}
|
||||
// Ohne Gegner (Flucht, Selbstheilung) zaehlt der Held
|
||||
// selbst als Ziel.
|
||||
KeyCode::Enter => {
|
||||
let ziel = match game.enemies.is_empty() {
|
||||
true => CreatureRef::Player,
|
||||
false => CreatureRef::Enemy(*target),
|
||||
};
|
||||
Response::Command(Command::UseAbility {
|
||||
ability: abilities[*pick].clone(),
|
||||
target,
|
||||
target: ziel,
|
||||
})
|
||||
}
|
||||
KeyCode::Char('.') => Response::Command(Command::Wait),
|
||||
|
||||
Reference in New Issue
Block a user