Nach Enter stand unspent_points auf 0, waehrend der Screen den Punkt noch vorgemerkt hielt — die Anzeige rechnete 0 - 1 auf u32 und stuerzte ab. Der Screen leert sein Feld, waehrend er das Kommando uebergibt, dazu saturating_sub an den zwei Lesestellen: unspent_points kann sich auch unter dem offenen Screen aendern. Erster UI-Test des Projekts. handle_key ist ohne Terminal aufrufbar, und sobald ein Screen Zustand haelt, ist die Naht zum Spielzustand eine Fehlerquelle wie jede andere.
392 lines
17 KiB
Rust
392 lines
17 KiB
Rust
//! Screen-Stack und Antwort — Ticket 11, im Durchstich (12) bestaetigt.
|
|
|
|
use ratatui::crossterm::event::KeyCode;
|
|
|
|
use crate::data::Registries;
|
|
use crate::game::{Attribute, Cheat, Command, CreatureRef, Direction, Game};
|
|
|
|
/// Popups sind gewoehnliche Screens, die sich beim Rendern als Overlay
|
|
/// ausweisen — ein gemeinsamer `Vec<Screen>` statt `current` plus
|
|
/// `Option<Popup>`, weil Popup ueber Popup real ist.
|
|
pub enum Screen {
|
|
MainMenu,
|
|
Explore,
|
|
/// 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 {
|
|
pick: usize,
|
|
},
|
|
/// Zugleich der Verteil-Screen — **kein zwoelfter Screen** (Ticket 15).
|
|
CharacterSheet {
|
|
/// Cursor in `Attribute::ASSIGNABLE`.
|
|
pick: usize,
|
|
/// Vorgemerkte Punkte, noch nicht abgeschickt. Ein festes Feld statt
|
|
/// eines `Vec`, weil die fuenf verteilbaren Grundwerte eine feste
|
|
/// Liste sind.
|
|
pending: [u32; 5],
|
|
},
|
|
/// Vollbild, kein Overlay. Bewusst nicht die lazy Variante mit einer
|
|
/// Meldungszeile im Hauptmenue: Ein Abstiegs-Roguelike lebt davon, dass der
|
|
/// Lauf am Ende *erzaehlt* wird (Ticket 14).
|
|
GameOver,
|
|
/// Werkbank. Immer mit gebaut statt hinter `cfg(debug_assertions)`: Das
|
|
/// waere ein `#[cfg]` an der Variante *und* an jedem `match` darueber, fuer
|
|
/// ein Spiel, das niemand ausliefert. Ein `#[cfg]` reicht spaeter nach.
|
|
Debug {
|
|
pick: usize,
|
|
},
|
|
/// **Flache Optionsliste**, kein Knotengraph (M4): Was erst nach einer
|
|
/// Antwort erscheint, ist eine Option mit einer Bedingung.
|
|
Dialog {
|
|
pick: usize,
|
|
},
|
|
/// Zwei Spalten: Angebot links, eigenes Inventar rechts. `Tab` wechselt.
|
|
Shop {
|
|
pick: usize,
|
|
/// `false` = Angebot, `true` = Inventar. Ein `bool` statt eines Enums
|
|
/// mit zwei Werten, die nie mehr werden.
|
|
selling: bool,
|
|
},
|
|
// TODO(M5+): `CharacterCreation`, `QuestLog`, `Confirm` — elf sind es am
|
|
// Ende (11/14).
|
|
}
|
|
|
|
/// Der Screen **gibt zurueck, statt selbst zu schieben**: sonst haelt er ein
|
|
/// `&mut` auf genau die `Vec`, in der er liegt. Nebeneffekt — `Response::Command`
|
|
/// ist der einzige Weg nach `game/`.
|
|
pub enum Response {
|
|
None,
|
|
Pop,
|
|
Push(Screen),
|
|
Command(Command),
|
|
Quit,
|
|
}
|
|
|
|
impl Screen {
|
|
pub fn sheet() -> Screen {
|
|
Screen::CharacterSheet { pick: 0, pending: [0; 5] }
|
|
}
|
|
|
|
pub fn is_overlay(&self) -> bool {
|
|
// TODO(M2): `Confirm` ist das erste echte Overlay. Bis dahin haelt die
|
|
// Regel „ein Command schliesst alle Overlays" nur die Luft.
|
|
false
|
|
}
|
|
|
|
/// **Befund aus M3:** Ticket 11 hatte `handle_key(&mut self, key)` ohne
|
|
/// Kontext festgelegt. Der Kampf-Screen muss aber wissen, wie viele
|
|
/// Faehigkeiten es gibt, um den Cursor zu klemmen, und welche der Cursor
|
|
/// meint. Lesender Zugriff genuegt — die Regel „ein Screen fasst `Game`
|
|
/// nicht an" bleibt, weil nur `Response::Command` etwas aendert.
|
|
pub fn handle_key(&mut self, key: KeyCode, game: &Game, reg: &Registries) -> Response {
|
|
match self {
|
|
Screen::MainMenu => match key {
|
|
KeyCode::Enter => Response::Push(Screen::Explore),
|
|
KeyCode::Char('q') | KeyCode::Esc => Response::Quit,
|
|
_ => Response::None,
|
|
},
|
|
|
|
Screen::Explore => match key {
|
|
// Pfeiltasten *und* hjkl — drei zusaetzliche Arme sind die
|
|
// billige Hoeflichkeit gegenueber Vim-Fingern (Ticket 11).
|
|
KeyCode::Up | KeyCode::Char('k') => {
|
|
Response::Command(Command::Move(Direction::North))
|
|
}
|
|
KeyCode::Down | KeyCode::Char('j') => {
|
|
Response::Command(Command::Move(Direction::South))
|
|
}
|
|
KeyCode::Left | KeyCode::Char('h') => {
|
|
Response::Command(Command::Move(Direction::West))
|
|
}
|
|
KeyCode::Right | KeyCode::Char('l') => {
|
|
Response::Command(Command::Move(Direction::East))
|
|
}
|
|
KeyCode::Char('.') => Response::Command(Command::Wait),
|
|
KeyCode::Char('>') => Response::Command(Command::Descend),
|
|
KeyCode::Char('g') => Response::Command(Command::PickUp),
|
|
KeyCode::Char('t') => Response::Command(Command::Talk),
|
|
KeyCode::F(12) => Response::Push(Screen::Debug { pick: 0 }),
|
|
// Screen-Navigation ist reine UI und fasst `Game` nie an.
|
|
KeyCode::Char('c') => Response::Push(Screen::sheet()),
|
|
KeyCode::Char('i') => Response::Push(Screen::Inventory { pick: 0 }),
|
|
KeyCode::Esc => Response::Pop,
|
|
_ => Response::None,
|
|
},
|
|
|
|
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') => {
|
|
*cursor = cursor.saturating_sub(1);
|
|
Response::None
|
|
}
|
|
KeyCode::Down | KeyCode::Char('j') => {
|
|
*cursor = (*cursor + 1).min(len.saturating_sub(1));
|
|
Response::None
|
|
}
|
|
// 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: ziel,
|
|
})
|
|
}
|
|
KeyCode::Char('.') => Response::Command(Command::Wait),
|
|
KeyCode::Char('c') => Response::Push(Screen::sheet()),
|
|
// Derselbe Screen wie draussen. `rejects` verbietet
|
|
// `UseItem` im Kampf nicht — es fehlte nur die Tuer.
|
|
KeyCode::Char('i') => Response::Push(Screen::Inventory { pick: 0 }),
|
|
KeyCode::F(12) => Response::Push(Screen::Debug { pick: 0 }),
|
|
_ => Response::None,
|
|
}
|
|
}
|
|
|
|
Screen::Inventory { pick } => {
|
|
let n = game.player.inventory.len();
|
|
if n > 0 {
|
|
*pick = (*pick).min(n - 1);
|
|
}
|
|
|
|
match key {
|
|
KeyCode::Up | KeyCode::Char('k') => {
|
|
*pick = pick.saturating_sub(1);
|
|
Response::None
|
|
}
|
|
KeyCode::Down | KeyCode::Char('j') => {
|
|
*pick = (*pick + 1).min(n.saturating_sub(1));
|
|
Response::None
|
|
}
|
|
KeyCode::Enter | KeyCode::Char('u') if n > 0 => {
|
|
Response::Command(Command::UseItem(*pick))
|
|
}
|
|
KeyCode::Char('e') if n > 0 => Response::Command(Command::Equip(*pick)),
|
|
KeyCode::Char('d') if n > 0 => Response::Command(Command::Drop(*pick)),
|
|
// Ablegen ueber die Slot-Reihenfolge aus der Registry —
|
|
// der Charakterbogen mauert kein Layout (Ticket 02).
|
|
KeyCode::Char(c @ '1'..='9') => {
|
|
let index = (c as u8 - b'1') as usize;
|
|
match reg.slots_ordered().get(index) {
|
|
Some(slot) => {
|
|
Response::Command(Command::Unequip(slot.id.clone()))
|
|
}
|
|
None => Response::None,
|
|
}
|
|
}
|
|
KeyCode::Esc | KeyCode::Char('i') | KeyCode::Char('q') => Response::Pop,
|
|
_ => Response::None,
|
|
}
|
|
}
|
|
|
|
Screen::CharacterSheet { pick, pending } => {
|
|
// `saturating_sub`, weil `unspent_points` sich unter dem
|
|
// offenen Screen aendern kann — ein Aufstieg im selben Zug.
|
|
let offen = game.unspent_points.saturating_sub(pending.iter().sum::<u32>());
|
|
match key {
|
|
KeyCode::Up | KeyCode::Char('k') => {
|
|
*pick = pick.saturating_sub(1);
|
|
Response::None
|
|
}
|
|
KeyCode::Down | KeyCode::Char('j') => {
|
|
*pick = (*pick + 1).min(Attribute::ASSIGNABLE.len() - 1);
|
|
Response::None
|
|
}
|
|
KeyCode::Right | KeyCode::Char('+' | 'l') if offen > 0 => {
|
|
pending[*pick] += 1;
|
|
Response::None
|
|
}
|
|
KeyCode::Left | KeyCode::Char('-' | 'h') if pending[*pick] > 0 => {
|
|
pending[*pick] -= 1;
|
|
Response::None
|
|
}
|
|
// **Alle** Punkte in einem Kommando und damit in einem Zug
|
|
// (Ticket 15).
|
|
KeyCode::Enter => {
|
|
let punkte = Attribute::ASSIGNABLE
|
|
.iter()
|
|
.zip(pending.iter())
|
|
.filter(|(_, n)| **n > 0)
|
|
.map(|(a, n)| (*a, *n))
|
|
.collect();
|
|
// **Vorgemerktes wird beim Abschicken verbraucht.** Ohne
|
|
// das behaelt der Screen Punkte, die das Spiel schon
|
|
// abgezogen hat — und rechnet `0 - 1` auf `u32`.
|
|
*pending = [0; 5];
|
|
Response::Command(Command::AssignPoints(punkte))
|
|
}
|
|
KeyCode::Esc | KeyCode::Char('c' | 'q') => Response::Pop,
|
|
_ => Response::None,
|
|
}
|
|
}
|
|
|
|
Screen::Dialog { pick } => {
|
|
let options = game.dialog_options(reg);
|
|
match key {
|
|
KeyCode::Up | KeyCode::Char('k') => {
|
|
*pick = pick.saturating_sub(1);
|
|
Response::None
|
|
}
|
|
KeyCode::Down | KeyCode::Char('j') => {
|
|
*pick = (*pick + 1).min(options.len().saturating_sub(1));
|
|
Response::None
|
|
}
|
|
KeyCode::Enter | KeyCode::Char('1'..='9') => {
|
|
let i = match key {
|
|
KeyCode::Char(c @ '1'..='9') => (c as u8 - b'1') as usize,
|
|
_ => *pick,
|
|
};
|
|
match options.get(i) {
|
|
Some(_) => Response::Command(Command::Choose(i)),
|
|
None => Response::None,
|
|
}
|
|
}
|
|
KeyCode::Esc | KeyCode::Char('q') => Response::Pop,
|
|
_ => Response::None,
|
|
}
|
|
}
|
|
|
|
Screen::Shop { pick, selling } => {
|
|
let n = if *selling {
|
|
game.player.inventory.len()
|
|
} else {
|
|
game.shop_stock().map_or(0, |s| s.len())
|
|
};
|
|
if n > 0 {
|
|
*pick = (*pick).min(n - 1);
|
|
}
|
|
|
|
match key {
|
|
KeyCode::Tab | KeyCode::Left | KeyCode::Right => {
|
|
*selling = !*selling;
|
|
*pick = 0;
|
|
Response::None
|
|
}
|
|
KeyCode::Up | KeyCode::Char('k') => {
|
|
*pick = pick.saturating_sub(1);
|
|
Response::None
|
|
}
|
|
KeyCode::Down | KeyCode::Char('j') => {
|
|
*pick = (*pick + 1).min(n.saturating_sub(1));
|
|
Response::None
|
|
}
|
|
KeyCode::Enter if n > 0 && *selling => {
|
|
Response::Command(Command::Sell(*pick))
|
|
}
|
|
KeyCode::Enter if n > 0 => Response::Command(Command::Buy(*pick)),
|
|
KeyCode::Esc | KeyCode::Char('q') => Response::Pop,
|
|
_ => Response::None,
|
|
}
|
|
}
|
|
|
|
Screen::Debug { pick } => {
|
|
let items = reg.items_ordered();
|
|
if !items.is_empty() {
|
|
*pick = (*pick).min(items.len() - 1);
|
|
}
|
|
|
|
match key {
|
|
KeyCode::Up | KeyCode::Char('k') => {
|
|
*pick = pick.saturating_sub(1);
|
|
Response::None
|
|
}
|
|
KeyCode::Down | KeyCode::Char('j') => {
|
|
*pick = (*pick + 1).min(items.len().saturating_sub(1));
|
|
Response::None
|
|
}
|
|
KeyCode::Enter if !items.is_empty() => {
|
|
Response::Command(Command::Debug(Cheat::Give(items[*pick].id.clone())))
|
|
}
|
|
KeyCode::Char('h') => Response::Command(Command::Debug(Cheat::Heal)),
|
|
KeyCode::Char('>') => Response::Command(Command::Debug(Cheat::Descend)),
|
|
KeyCode::Char('x') => Response::Command(Command::Debug(Cheat::ClearRoom)),
|
|
KeyCode::Esc | KeyCode::F(12) | KeyCode::Char('q') => Response::Pop,
|
|
_ => Response::None,
|
|
}
|
|
}
|
|
|
|
Screen::GameOver => Response::Quit,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::data::load_mods;
|
|
use crate::game::{Creature, Rng};
|
|
|
|
fn spiel() -> (Game, Registries) {
|
|
let (reg, _) = load_mods().expect("core muss laden");
|
|
let game = Game::new(Creature::new("Test"), ®, Rng::new(1)).expect("Etage 1");
|
|
(game, reg)
|
|
}
|
|
|
|
/// Gefunden im Spiel: Nach dem Abschicken stand `unspent_points` auf 0,
|
|
/// waehrend der Screen den Punkt noch vorgemerkt hielt — `0 - 1` auf `u32`.
|
|
/// Der erste Test, der **Screen-Zustand gegen Spielzustand** prueft; die
|
|
/// Trennung aus Ticket 11 macht genau diese Naht zur Fehlerquelle.
|
|
#[test]
|
|
fn vorgemerkte_punkte_werden_beim_abschicken_verbraucht() {
|
|
let (mut game, reg) = spiel();
|
|
game.unspent_points = 1;
|
|
let mut screen = Screen::sheet();
|
|
|
|
screen.handle_key(KeyCode::Char('+'), &game, ®);
|
|
let Screen::CharacterSheet { pending, .. } = &screen else { panic!("falscher Screen") };
|
|
assert_eq!(pending[0], 1, "der Punkt ist vorgemerkt");
|
|
|
|
let antwort = screen.handle_key(KeyCode::Enter, &game, ®);
|
|
let Response::Command(cmd) = antwort else { panic!("Enter muss abschicken") };
|
|
assert!(matches!(game.execute(cmd, ®), crate::game::Outcome::TurnPassed));
|
|
assert_eq!(game.unspent_points, 0);
|
|
|
|
let Screen::CharacterSheet { pending, .. } = &screen else { panic!("falscher Screen") };
|
|
assert_eq!(pending[0], 0, "und im Screen nicht mehr vorgemerkt");
|
|
|
|
// Der Zustand, der abgestuerzt ist: kein offener Punkt, keine Vormerkung.
|
|
screen.handle_key(KeyCode::Char('+'), &game, ®);
|
|
let Screen::CharacterSheet { pending, .. } = &screen else { panic!("falscher Screen") };
|
|
assert_eq!(pending[0], 0, "ohne offene Punkte laesst sich nichts vormerken");
|
|
}
|
|
}
|