Autosave beim Betreten einer Etage und beim Beenden, ein Slot, bei Tod geloescht. Der Save ist der Baum selbst — keine eigenen Save-Structs, die man zweimal pflegt. - Kacheln als ASCII-Zeilen wie ein Prefab, nicht 3200 Enum-Eintraege - atomar geschrieben: tmp plus rename, der einzige Stand ueberlebt einen Abbruch - fremdes Schema wird abgelehnt, die Datei bleibt liegen - fehlende Mods verwerfen Eintraege, nie den Lauf - Confirm-Screen vor dem Spiel, wenn der Save Eintraege verloren hat Fuenf Befunde: die Ereigniswarteschlange gehoert nicht in den Save, die Feldreihenfolge ist Teil des TOML-Formats, Serialize fuer Floor muss von Hand, Confirm braucht on_no, und die Rueckfrage galt zuerst auch fuer Mod-Ladewarnungen.
410 lines
18 KiB
Rust
410 lines
18 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.
|
|
#[derive(Clone)]
|
|
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,
|
|
/// Rueckfrage. Ticket 09 wollte `on_yes: Box<Response>` und ein implizites
|
|
/// „Nein = wegklicken" — das reicht nicht: Beim Ladefall heisst Nein
|
|
/// *beenden und den Mod nachinstallieren*, denn diese Wahl hat der Spieler
|
|
/// nur **vor** dem Laden. Das symmetrische Paar ist ein Feld mehr und
|
|
/// spart die ungeschriebene Regel.
|
|
Confirm {
|
|
text: String,
|
|
on_yes: Box<Response>,
|
|
on_no: Box<Response>,
|
|
},
|
|
/// 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/`.
|
|
#[derive(Clone)]
|
|
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::Confirm { on_yes, on_no, .. } => match key {
|
|
KeyCode::Char('j' | 'y') | KeyCode::Enter => (**on_yes).clone(),
|
|
KeyCode::Char('n' | 'q') | KeyCode::Esc => (**on_no).clone(),
|
|
_ => 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");
|
|
}
|
|
}
|