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

@@ -233,3 +233,22 @@ und `data/` bleiben ratatui-frei, weil ein Farbname eine Zeichenkette ist.
Kulisse. Kulisse.
- **14 Kampfformeln**: `core:attack` und `core:flee` müssen als Fähigkeiten im Kernspiel existieren — - **14 Kampfformeln**: `core:attack` und `core:flee` müssen als Fähigkeiten im Kernspiel existieren —
ohne sie hat `UseAbility` weder Basisangriff noch Flucht. ohne sie hat `UseAbility` weder Basisangriff noch Flucht.
### Nachtrag — zwei Cursor im Kampf-Screen (nach M4)
Ticket 11 hatte die Zielwahl den Zifferntasten überlassen: `Enter` traf den ersten Gegner, `1`–`9`
feuerten sofort auf den n-ten. Das reicht, solange man nur zuschlägt, und bricht, sobald die Wahl des
Ziels eine Entscheidung ist — dieselbe Taste hätte je nach Absicht zwei Bedeutungen.
Der Screen trägt jetzt **zwei Cursor**, `pick` für die Fähigkeit und `target` für den Gegner, und ein
`targeting: bool` sagt, welcher aktiv ist. `Tab` schaltet um, der Titel des aktiven Rahmens trägt
`· aktiv`. Das ist dieselbe Bedienung wie im Laden — **eine** Umschaltregel für alle zweispaltigen
Screens statt einer je Screen.
Die Ziffern bleiben, aber sie *springen* nur noch aufs Ziel, statt zu feuern; ausgelöst wird
ausschließlich mit `Enter`. Zwei gleichzeitig sichtbare Cursor mit Pfeilen auf beiden Achsen wären die
Alternative gewesen — weniger Tastendrücke, aber eine Bedienung, die es sonst nirgends im Spiel gibt.
Dazu zeigt die Gegnerliste unter jedem Lebensbalken die **Ansage** aus Ticket 14 (`↳ Wuchtschlag →
dich`, sonst `↳ zögert`). Sie steht bewusst als eigene Zeile und nicht als Kürzel hinter dem Namen:
Sie ist die Information, auf der die Entscheidung dieses Zuges beruht.

View File

@@ -259,3 +259,28 @@ verschieben, wenn später jemand die Liste wieder füllt.
Damit ist die Regel dieses Tickets vollständig: Die Reihenfolge steht vor dem Zug fest, *und* sie Damit ist die Regel dieses Tickets vollständig: Die Reihenfolge steht vor dem Zug fest, *und* sie
endet, sobald ihre Grundlage weg ist. endet, sobald ihre Grundlage weg ist.
### Nachtrag — die Ansage (Taktik-Ebene, nach M4)
Der Zugablauf bekommt einen **siebten Schritt**: Am Ende jedes Zuges legt jeder lebende Gegner fest,
was er im nächsten tun wird. Ausgewertet wird dieselbe `ai`-Liste nach derselben Regel — erster
passender *und verfügbarer* Eintrag —, nur eine Runde früher. Das Ergebnis steht als `intent` am
`Creature` und wird im Kampf-Screen unter dem Lebensbalken angezeigt.
**Verbindlich, nicht als Vorschau.** Der Gegner führt genau das aus, was dasteht. Die Alternative wäre
gewesen, die `ai`-Liste bei jedem Bild neu auszuwerten und das Ergebnis als Vermutung anzuzeigen — das
hätte keinen Zustand gekostet, aber auch keine Taktik erzeugt: Eine Anzeige, die sich mit der Lage
mitdreht, sagt nur, was *jetzt* gälte, und ist genau dann falsch, wenn der Spieler etwas unternimmt.
Der Gewinn liegt im **Fenster zwischen Ansage und Ausführung**. Vor dem Ausführen wird `ability_block`
erneut geprüft; schlägt es an, verliert der Gegner den Zug (`„X kommt nicht dazu — Nicht genug
Ausdauer."`). Damit wird jede Wirkung auf Ausdauer, Abklingzeit oder Bedingung zu einem *Konter*, und
die Sperrgründe, die Ticket 14 ohnehin schon berechnet, bekommen einen zweiten Nutzen.
Eine Ansage wird beim Ausführen verbraucht (`take`). Wer ohne Ansage an die Reihe kommt, tut nichts —
das trifft nur Gegner, die mitten im Zug auftauchen, und die stehen ohnehin nicht in der
Initiativreihenfolge dieses Zuges. **Ein** Ansagepunkt, kein zweiter beim Kampfbeginn.
Ausdrücklich **nicht** dazugekommen ist ein Ziel an der Ansage: Die `ai` greift ausschließlich den
Helden an, `intent` trägt deshalb nur die Fähigkeits-ID. Ein Zielfeld käme mit dem ersten Gegner, der
seine Verbündeten heilt.

View File

@@ -118,6 +118,20 @@ leerer `enemies` bricht den Zug jetzt ab.
Dazu **eine bewusst offene Stelle**: `Npc.triggers` ist nicht gebaut, weil `triggers_for` NPCs nicht Dazu **eine bewusst offene Stelle**: `Npc.triggers` ist nicht gebaut, weil `triggers_for` NPCs nicht
einsammelt — ein Feld ohne Leser wäre eine Zusage, die niemand einlöst. TODO steht im Code. einsammelt — ein Feld ohne Leser wäre eine Zusage, die niemand einlöst. TODO steht im Code.
**Nach M4 — die Ansage** (58 Tests)
Kein Meilenstein, eine Nachforderung aus dem Spielen: Gegner sagen am Zugende an, was sie im nächsten
tun, und der Spieler wählt sein Ziel mit einem eigenen Cursor. Zwei Entscheidungen dahinter, beide in
die Tickets zurückgetragen:
| Frage | Entschieden | Warum |
|---|---|---|
| Ansage verbindlich oder Vorschau? | **verbindlich** (14) | Eine Anzeige, die sich mit der Lage mitdreht, ist genau dann falsch, wenn der Spieler handelt. Verbindlich heißt störbar — das Fenster zwischen Ansage und Ausführung *ist* die Taktik |
| Zielwahl mit Pfeilen auf zwei Achsen oder mit Tab? | **Tab** (11) | Dieselbe Umschaltregel wie im Laden. Eine Bedienung für alle zweispaltigen Screens statt einer je Screen |
Der Zugablauf aus **14** hat damit einen siebten Schritt, `Creature` ein Feld `intent`, und die
Sperrgründe, die `ability_block` ohnehin berechnete, haben einen zweiten Nutzen bekommen.
**Drei Befunde aus M1, die nur der Compiler liefern konnte** — alle in die Tickets zurückgetragen: **Drei Befunde aus M1, die nur der Compiler liefern konnte** — alle in die Tickets zurückgetragen:
- **11**: Der Grenztest in seiner wörtlichen Fassung schlägt beim eigenen Doc-Kommentar an. Muss Kommentarzeilen ausnehmen. - **11**: Der Grenztest in seiner wörtlichen Fassung schlägt beim eigenen Doc-Kommentar an. Muss Kommentarzeilen ausnehmen.

View File

@@ -14,6 +14,19 @@ Auslesen des Ziels eingesammelt.
Gegner werden in der [Registry](#registry) `[[creatures]]` definiert — mit Grundwerten, Tags, Gegner werden in der [Registry](#registry) `[[creatures]]` definiert — mit Grundwerten, Tags,
Fähigkeiten und einer geordneten `ai`-Liste, deren erster passender und verfügbarer Eintrag gewinnt. Fähigkeiten und einer geordneten `ai`-Liste, deren erster passender und verfügbarer Eintrag gewinnt.
## Ansage
Was ein [Wesen](#wesen) im nächsten [Zug](#zug) tun wird — eine [Fähigkeits](#fähigkeit)-ID, am Ende
des vorigen Zuges aus der `ai`-Liste bestimmt und im Kampf sichtbar.
Sie ist **verbindlich**: Der Gegner führt genau das aus, was dasteht. Eine Vorschau, die sich mit der
Lage mitdreht, wäre genau dann falsch, wenn der Spieler etwas unternimmt — und damit als Grundlage
einer Entscheidung wertlos.
Verbindlich heißt zugleich **störbar**: Vor dem Ausführen wird erneut geprüft, ob die Fähigkeit
verfügbar ist. Wer dem Gegner dazwischen die Ausdauer nimmt oder die [Bedingung](#bedingung) kippt,
kostet ihn den Zug. Das Fenster zwischen Ansage und Ausführung ist der taktische Griff.
## Attribut ## Attribut
Eine benannte, veränderbare Größe an einem [Wesen](#wesen). Der Katalog ist fest und wird von der Engine Eine benannte, veränderbare Größe an einem [Wesen](#wesen). Der Katalog ist fest und wird von der Engine

View File

@@ -80,6 +80,11 @@ pub struct Creature {
/// mit demselben Ungueltigkeitsproblem beim Tod eines Gegners (Ticket 14). /// mit demselben Ungueltigkeitsproblem beim Tod eines Gegners (Ticket 14).
pub initiative: i32, pub initiative: i32,
pub cooldowns: HashMap<String, u32>, 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 /// Aus welcher `creatures`-Definition dieses Wesen stammt — der Held hat
/// keine. Traegt `ai`-Liste, Faehigkeiten und Trigger. /// keine. Traegt `ai`-Liste, Faehigkeiten und Trigger.
pub def: Option<String>, pub def: Option<String>,
@@ -102,6 +107,7 @@ impl Creature {
modifiers: Vec::new(), modifiers: Vec::new(),
initiative: 0, initiative: 0,
cooldowns: HashMap::new(), cooldowns: HashMap::new(),
intent: None,
def: None, def: None,
abilities: Vec::new(), abilities: Vec::new(),
} }

View File

@@ -523,7 +523,13 @@ impl Game {
self.drain_events(reg); 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; self.turn += 1;
Outcome::TurnPassed Outcome::TurnPassed
} }
@@ -924,6 +930,13 @@ impl Game {
self.count_of("core:gold") 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 — /// Der Anzeigename eines Exemplars: Affixe werden vorangestellt —
/// „Flammendes Eisenschwert" (Ticket 07). /// „Flammendes Eisenschwert" (Ticket 07).
pub fn item_label(&self, inst: &ItemInstance, reg: &Registries) -> String { 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. /// Legt fest, was ein Gegner im **naechsten** Zug tut. Erster passender
/// Passt nichts, setzt der Gegner aus — einer, der die Runde blockiert, /// **und verfuegbarer** Eintrag der `ai`-Liste gewinnt (Ticket 14) — nur
/// waere schlimmer als einer, der nichts tut (Ticket 14). /// 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) { fn enemy_acts(&mut self, i: usize, reg: &Registries) {
let me = CreatureRef::Enemy(i); let me = CreatureRef::Enemy(i);
if !self.creature(me).is_alive() { if !self.creature(me).is_alive() {
return; 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; 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) { 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"); 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");
}
} }

View File

@@ -33,7 +33,7 @@ impl App {
let shows_combat = self.screens.iter().any(|s| matches!(s, Screen::Combat { .. })); let shows_combat = self.screens.iter().any(|s| matches!(s, Screen::Combat { .. }));
if in_combat && !shows_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 { } else if !in_combat {
self.screens.retain(|s| !matches!(s, Screen::Combat { .. })); self.screens.retain(|s| !matches!(s, Screen::Combat { .. }));
} }
@@ -132,7 +132,9 @@ fn draw(frame: &mut Frame, app: &App) {
match screen { match screen {
Screen::MainMenu => draw_main_menu(frame, area, app), Screen::MainMenu => draw_main_menu(frame, area, app),
Screen::Explore => draw_explore(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::Inventory { pick } => draw_inventory(frame, area, app, *pick),
Screen::CharacterSheet => draw_sheet(frame, area, app), Screen::CharacterSheet => draw_sheet(frame, area, app),
Screen::Dialog { pick } => draw_dialog(frame, area, app, *pick), 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 /// Menue-Kampf (Charter: Dungeon Master / Etrian Odyssey). Links die Gegner mit
/// Ziffern, rechts die Faehigkeiten mit Kosten und **Sperrgrund** — der kommt /// Ziffern, rechts die Faehigkeiten mit Kosten und **Sperrgrund** — der kommt
/// aus dem Spielkern, nicht aus der UI (Ticket 14). /// 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 block = Block::bordered().title(" Kampf ");
let inner = block.inner(area); let inner = block.inner(area);
frame.render_widget(block, 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] = let [gegner_area, log_area] =
Layout::vertical([Constraint::Percentage(45), Constraint::Min(0)]).areas(links); 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(); let mut zeilen = Vec::new();
for (i, e) in app.game.enemies.iter().enumerate() { for (i, e) in app.game.enemies.iter().enumerate() {
let max = app 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 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 balken = "█".repeat((anteil * 10.0).round() as usize);
let gewaehlt = targeting && i == target;
let marken: String = e.modifiers.iter().map(|m| m.symbol).collect(); let marken: String = e.modifiers.iter().map(|m| m.symbol).collect();
zeilen.push(Line::from(format!( zeilen.push(
"{} {:<14} {:<10} {:>4.0}/{:<4.0} {marken}", Line::from(format!(
i + 1, "{} {} {:<12} {:<10} {:>4.0}/{:<4.0} {marken}",
e.name, if gewaehlt { "›" } else { " " },
balken, i + 1,
e.health.max(0.0), e.name,
max 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() { if zeilen.is_empty() {
zeilen.push(Line::from("—").style(Style::new().fg(Color::DarkGray))); zeilen.push(Line::from("—").style(Style::new().fg(Color::DarkGray)));
} }
frame.render_widget( 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, 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 Some(def) = app.registries.abilities.get(id) else { continue };
let block = app.game.ability_block(CreatureRef::Player, id, &app.registries); 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!( let text = format!(
"{zeiger} {:<16} {:>3} Ausd. {}", "{zeiger} {:<16} {:>3} Ausd. {}",
def.name, 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(namen.join(", ")).style(Style::new().fg(Color::Green)));
} }
zeilen.push( 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)), .style(Style::new().fg(Color::DarkGray)),
); );
frame.render_widget( 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, rechts,
); );
} }

View File

@@ -11,10 +11,15 @@ use crate::game::{Command, CreatureRef, Direction, Game};
pub enum Screen { pub enum Screen {
MainMenu, MainMenu,
Explore, Explore,
/// Menue-Kampf (Charter): Faehigkeitsliste, Zielwahl mit den Zifferntasten. /// Menue-Kampf (Charter): Faehigkeitsliste links, Gegnerliste rechts.
Combat { Combat {
/// Cursor in der Faehigkeitsliste. /// Cursor in der Faehigkeitsliste.
pick: usize, 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. /// Zwei Bereiche: Inventar mit Cursor, Ausrüstung als Registry-Schleife.
Inventory { Inventory {
@@ -98,37 +103,54 @@ impl Screen {
_ => Response::None, _ => Response::None,
}, },
Screen::Combat { pick } => { Screen::Combat { pick, target, targeting } => {
let abilities = game.abilities_of(CreatureRef::Player, reg); let abilities = game.abilities_of(CreatureRef::Player, reg);
if abilities.is_empty() { if abilities.is_empty() {
return Response::None; return Response::None;
} }
*pick = (*pick).min(abilities.len() - 1); *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 { match key {
KeyCode::Tab | KeyCode::BackTab => {
*targeting = !*targeting;
Response::None
}
KeyCode::Up | KeyCode::Char('k') => { KeyCode::Up | KeyCode::Char('k') => {
*pick = pick.saturating_sub(1); *cursor = cursor.saturating_sub(1);
Response::None Response::None
} }
KeyCode::Down | KeyCode::Char('j') => { KeyCode::Down | KeyCode::Char('j') => {
*pick = (*pick + 1).min(abilities.len() - 1); *cursor = (*cursor + 1).min(len.saturating_sub(1));
Response::None Response::None
} }
// Ziel ueber die Zifferntasten; ohne Gegner (Flucht, // Die Ziffern springen aufs Ziel, sie feuern nicht — sonst
// Selbstheilung) zaehlt der Held selbst als Ziel. // haette dieselbe Taste je nach Seite zwei Bedeutungen.
KeyCode::Enter | KeyCode::Char('1'..='9') => { KeyCode::Char(c @ '1'..='9') => {
let index = match key { let i = (c as u8 - b'1') as usize;
KeyCode::Char(c @ '1'..='9') => (c as u8 - b'1') as usize, if i < game.enemies.len() {
_ => 0, *target = i;
}; }
let target = if game.enemies.is_empty() { Response::None
CreatureRef::Player }
} else { // Ohne Gegner (Flucht, Selbstheilung) zaehlt der Held
CreatureRef::Enemy(index.min(game.enemies.len() - 1)) // selbst als Ziel.
KeyCode::Enter => {
let ziel = match game.enemies.is_empty() {
true => CreatureRef::Player,
false => CreatureRef::Enemy(*target),
}; };
Response::Command(Command::UseAbility { Response::Command(Command::UseAbility {
ability: abilities[*pick].clone(), ability: abilities[*pick].clone(),
target, target: ziel,
}) })
} }
KeyCode::Char('.') => Response::Command(Command::Wait), KeyCode::Char('.') => Response::Command(Command::Wait),