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
This commit is contained in:
2026-08-12 20:31:35 +02:00
parent 197f62ce33
commit 7ed15afaf9
22 changed files with 818 additions and 26 deletions

View File

@@ -38,6 +38,23 @@ impl App {
self.screens.retain(|s| !matches!(s, Screen::Combat { .. }));
}
// Ticket 11: `Game.talking_to` ist die Wahrheit, der Screen folgt —
// dieselbe Regel wie beim Kampf.
let talking = self.game.talking_to.is_some();
let shows_dialog = self.screens.iter().any(|s| matches!(s, Screen::Dialog { .. }));
if talking && !shows_dialog {
self.screens.push(Screen::Dialog { pick: 0 });
} else if !talking {
self.screens
.retain(|s| !matches!(s, Screen::Dialog { .. } | Screen::Shop { .. }));
}
// Die Aktion `open_shop` setzt nur eine Absicht; den Screen schiebt die
// Schleife, weil `game/` keine Screens kennt.
if std::mem::take(&mut self.game.open_shop) {
self.screens.push(Screen::Shop { pick: 0, selling: false });
}
if self.game.over.is_some()
&& !self.screens.iter().any(|s| matches!(s, Screen::GameOver))
{
@@ -75,6 +92,11 @@ pub fn run(game: Game, registries: Registries, warnings: usize, log_name: Option
match screen.handle_key(key.code, game, registries) {
Response::None => {}
Response::Pop => {
// Das Gespraech zu verlassen beendet es auch im Kern —
// sonst schoebe `sync_screens` den Screen sofort zurueck.
if matches!(app.screens.last(), Some(Screen::Dialog { .. })) {
app.game.talking_to = None;
}
app.screens.pop();
if app.screens.is_empty() {
break Ok(());
@@ -113,6 +135,8 @@ fn draw(frame: &mut Frame, app: &App) {
Screen::Combat { pick } => draw_combat(frame, area, app, *pick),
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),
Screen::Shop { pick, selling } => draw_shop(frame, area, app, *pick, *selling),
Screen::GameOver => draw_game_over(frame, area, app),
}
}
@@ -150,7 +174,11 @@ fn draw_explore(frame: &mut Frame, area: Rect, app: &App) {
let [grid, bar] = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(inner);
frame.render_widget(
GridWidget { floor: &app.game.floor, player: app.game.player_pos },
GridWidget {
floor: &app.game.floor,
player: app.game.player_pos,
reg: &app.registries,
},
grid,
);
@@ -226,6 +254,94 @@ fn draw_inventory(frame: &mut Frame, area: Rect, app: &App, pick: usize) {
);
}
/// **Flache Optionsliste** — was erst nach einer Antwort erscheint, ist eine
/// Option mit einer Bedingung (M4).
fn draw_dialog(frame: &mut Frame, area: Rect, app: &App, pick: usize) {
let Some(npc) = app.game.talking_npc(&app.registries) else { return };
let mut zeilen = vec![
Line::from(npc.greeting.clone()).style(Style::new().fg(Color::Gray)),
Line::from(""),
];
for (i, option) in app.game.dialog_options(&app.registries).iter().enumerate() {
let zeiger = if i == pick { "›" } else { " " };
let farbe = if i == pick { Color::Yellow } else { Color::Reset };
zeilen.push(
Line::from(format!("{zeiger} {}. {}", i + 1, option.text))
.style(Style::new().fg(farbe)),
);
}
zeilen.push(Line::from(""));
zeilen.push(
Line::from("↑↓ wählen · Enter/1-9 antworten · Esc beenden")
.style(Style::new().fg(Color::DarkGray)),
);
frame.render_widget(
Paragraph::new(zeilen).block(Block::bordered().title(format!(" {} ", npc.name))),
area,
);
}
/// Zwei Spalten, damit die **Spanne** sichtbar ist: derselbe Gegenstand steht
/// links teurer als er rechts einbringt.
fn draw_shop(frame: &mut Frame, area: Rect, app: &App, pick: usize, selling: bool) {
let block = Block::bordered().title(format!(" Laden · {} Gold ", app.game.gold()));
let inner = block.inner(area);
frame.render_widget(block, area);
let [links, rechts] =
Layout::horizontal([Constraint::Percentage(50), Constraint::Min(0)]).areas(inner);
let zeile = |i: usize, text: String, aktiv: bool| {
let zeiger = if aktiv && i == pick { "›" } else { " " };
let farbe = if aktiv && i == pick { Color::Yellow } else { Color::Reset };
Line::from(format!("{zeiger} {text}")).style(Style::new().fg(farbe))
};
let mut angebot: Vec<Line> = app
.game
.shop_stock()
.unwrap_or(&[])
.iter()
.enumerate()
.map(|(i, inst)| {
let name = app.game.item_label(inst, &app.registries);
let preis = app.game.buy_price(inst, &app.registries);
zeile(i, format!("{name:<28} {preis:>5} G"), !selling)
})
.collect();
if angebot.is_empty() {
angebot.push(Line::from("ausverkauft").style(Style::new().fg(Color::DarkGray)));
}
let mut eigenes: Vec<Line> = app
.game
.player
.inventory
.iter()
.enumerate()
.map(|(i, inst)| {
let name = app.game.item_label(inst, &app.registries);
let preis = app.game.sell_price(inst, &app.registries);
zeile(i, format!("{name:<28} {preis:>5} G"), selling)
})
.collect();
eigenes.push(Line::from(""));
eigenes.push(
Line::from("Tab wechseln · Enter handeln · Esc")
.style(Style::new().fg(Color::DarkGray)),
);
frame.render_widget(
Paragraph::new(angebot).block(Block::bordered().title(" Angebot ")),
links,
);
frame.render_widget(
Paragraph::new(eigenes).block(Block::bordered().title(" Verkaufen ")),
rechts,
);
}
/// 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).