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:
118
src/ui/app.rs
118
src/ui/app.rs
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user