Files
rustkeep/src/ui/screen.rs
MDietermann 7ed15afaf9 feat(handel): NPC-Registry, Dialog, Laden und Stapelregel
Vierzehnte Registry: NPCs sind keine Wesen — kein Kampfzustand, keine
Initiative, keine Attribute. Ihre Antwortliste ist flach; was erst nach
einer Antwort erscheint, ist eine Option mit einer Bedingung.

- open_shop als elfte Aktion (game/ setzt eine Absicht, die UI schiebt)
- Talk/Choose/Buy/Sell, Talk wirkt auf das eigene Feld wie PickUp
- Preisspanne statt Goldkonto beim Haendler, Charisma in der Preisfunktion
- Warenkorb einmal bei der Etagenerzeugung gezogen
- Stapelregel aus Ticket 07 endlich gebaut: 50-120 Gold war 120 Eintraege
- Dialog- und Shop-Screen, NPC-Symbol im Grid
2026-08-12 20:31:35 +02:00

244 lines
10 KiB
Rust

//! Screen-Stack und Antwort — Ticket 11, im Durchstich (12) bestaetigt.
use ratatui::crossterm::event::KeyCode;
use crate::data::Registries;
use crate::game::{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, Zielwahl mit den Zifferntasten.
Combat {
/// Cursor in der Faehigkeitsliste.
pick: usize,
},
/// Zwei Bereiche: Inventar mit Cursor, Ausrüstung als Registry-Schleife.
Inventory {
pick: usize,
},
CharacterSheet,
/// 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,
/// **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 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),
// Screen-Navigation ist reine UI und fasst `Game` nie an.
KeyCode::Char('c') => Response::Push(Screen::CharacterSheet),
KeyCode::Char('i') => Response::Push(Screen::Inventory { pick: 0 }),
KeyCode::Esc => Response::Pop,
_ => Response::None,
},
Screen::Combat { pick } => {
let abilities = game.abilities_of(CreatureRef::Player, reg);
if abilities.is_empty() {
return Response::None;
}
*pick = (*pick).min(abilities.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(abilities.len() - 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))
};
Response::Command(Command::UseAbility {
ability: abilities[*pick].clone(),
target,
})
}
KeyCode::Char('.') => Response::Command(Command::Wait),
KeyCode::Char('c') => Response::Push(Screen::CharacterSheet),
_ => 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 => match key {
KeyCode::Esc | KeyCode::Char('c') | KeyCode::Char('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::GameOver => Response::Quit,
}
}
}