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

@@ -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, &reg);
assert_eq!(game.enemies[0].intent.as_deref(), Some("core:power_strike"));
assert_eq!(game.intent_label(0, &reg).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, &reg);
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");
}
}