diff --git a/.scratch/rustkeep-1.0/issues/11-ui-geruest-screen-modell.md b/.scratch/rustkeep-1.0/issues/11-ui-geruest-screen-modell.md index 05b4715..afb873d 100644 --- a/.scratch/rustkeep-1.0/issues/11-ui-geruest-screen-modell.md +++ b/.scratch/rustkeep-1.0/issues/11-ui-geruest-screen-modell.md @@ -233,3 +233,22 @@ und `data/` bleiben ratatui-frei, weil ein Farbname eine Zeichenkette ist. Kulisse. - **14 Kampfformeln**: `core:attack` und `core:flee` müssen als Fähigkeiten im Kernspiel existieren — 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. diff --git a/.scratch/rustkeep-1.0/issues/14-kampfformeln-und-rundenablauf.md b/.scratch/rustkeep-1.0/issues/14-kampfformeln-und-rundenablauf.md index b3ef225..5443ede 100644 --- a/.scratch/rustkeep-1.0/issues/14-kampfformeln-und-rundenablauf.md +++ b/.scratch/rustkeep-1.0/issues/14-kampfformeln-und-rundenablauf.md @@ -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 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. diff --git a/.scratch/rustkeep-1.0/map.md b/.scratch/rustkeep-1.0/map.md index c2038db..4146766 100644 --- a/.scratch/rustkeep-1.0/map.md +++ b/.scratch/rustkeep-1.0/map.md @@ -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 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: - **11**: Der Grenztest in seiner wörtlichen Fassung schlägt beim eigenen Doc-Kommentar an. Muss Kommentarzeilen ausnehmen. diff --git a/CONTEXT.md b/CONTEXT.md index 2207163..58af895 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -14,6 +14,19 @@ Auslesen des Ziels eingesammelt. 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. +## 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 Eine benannte, veränderbare Größe an einem [Wesen](#wesen). Der Katalog ist fest und wird von der Engine diff --git a/src/game/creature.rs b/src/game/creature.rs index 9f80c18..7a1a36a 100644 --- a/src/game/creature.rs +++ b/src/game/creature.rs @@ -80,6 +80,11 @@ pub struct Creature { /// mit demselben Ungueltigkeitsproblem beim Tod eines Gegners (Ticket 14). pub initiative: i32, pub cooldowns: HashMap, + /// **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, /// Aus welcher `creatures`-Definition dieses Wesen stammt — der Held hat /// keine. Traegt `ai`-Liste, Faehigkeiten und Trigger. pub def: Option, @@ -102,6 +107,7 @@ impl Creature { modifiers: Vec::new(), initiative: 0, cooldowns: HashMap::new(), + intent: None, def: None, abilities: Vec::new(), } diff --git a/src/game/mod.rs b/src/game/mod.rs index 019303f..3c94a53 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -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 { + 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"); + } } diff --git a/src/ui/app.rs b/src/ui/app.rs index 7c92572..97ca86e 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -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, ); } diff --git a/src/ui/screen.rs b/src/ui/screen.rs index 214f33a..3662b3d 100644 --- a/src/ui/screen.rs +++ b/src/ui/screen.rs @@ -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),