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:
@@ -16,7 +16,7 @@ use toml::Spanned;
|
||||
|
||||
use super::registries::{
|
||||
Ability, Affix, Category, CharacterOption, CreatureDef, FloorDef, Item, LootTable, ModifierDef,
|
||||
Prefab, Registries, Slot,
|
||||
Npc, Prefab, Registries, Slot,
|
||||
};
|
||||
|
||||
/// Genau drei harte Fehler (Ticket 04). Alles Uebrige ist Warnung plus
|
||||
@@ -261,6 +261,8 @@ struct ModFile {
|
||||
affixes: Vec<Spanned<toml::Value>>,
|
||||
#[serde(default)]
|
||||
loot_tables: Vec<Spanned<toml::Value>>,
|
||||
#[serde(default)]
|
||||
npcs: Vec<Spanned<toml::Value>>,
|
||||
/// **Keine** Tabelle mit `[[...]]`, sondern ein einzelner Block, der
|
||||
/// feldweise verschmilzt — die ausdrueckliche Ausnahme zu Ticket 03.
|
||||
#[serde(default)]
|
||||
@@ -300,6 +302,7 @@ impl_entry!(CreatureDef);
|
||||
impl_entry!(ModifierDef);
|
||||
impl_entry!(Affix);
|
||||
impl_entry!(LootTable);
|
||||
impl_entry!(Npc);
|
||||
|
||||
struct FileCtx<'a> {
|
||||
module: &'a str,
|
||||
@@ -510,6 +513,7 @@ pub fn load_mods() -> Result<(Registries, Vec<String>), LoadError> {
|
||||
parse_entries(parsed.modifiers, &ctx, &mut reg.modifiers, &mut warnings);
|
||||
parse_entries(parsed.affixes, &ctx, &mut reg.affixes, &mut warnings);
|
||||
parse_entries(parsed.loot_tables, &ctx, &mut reg.loot_tables, &mut warnings);
|
||||
parse_entries(parsed.npcs, &ctx, &mut reg.npcs, &mut warnings);
|
||||
|
||||
// `[balance]` verschmilzt **feldweise** (Ticket 14): Ein Mod, der
|
||||
// einen Knopf dreht, schreibt eine Zeile — und ein neuer Knopf in
|
||||
|
||||
@@ -6,5 +6,5 @@ pub mod registries;
|
||||
|
||||
pub use loader::{load_mods, write_log};
|
||||
pub use registries::{
|
||||
Balance, CreatureDef, FloorDef, LootEntry, Prefab, Registries, parse_range,
|
||||
Balance, CreatureDef, FloorDef, DialogOption, LootEntry, Npc, Prefab, Registries, parse_range,
|
||||
};
|
||||
|
||||
@@ -212,8 +212,14 @@ pub struct Balance {
|
||||
pub resistance_cap: f32,
|
||||
pub hit_floor: f32,
|
||||
pub hit_ceiling: f32,
|
||||
// TODO(M4/M5): `exp_base`, `exp_exponent` (Ticket 15) und `luck_factor`
|
||||
// (Ticket 16) — sie kommen mit ihren Lesern, nicht davor.
|
||||
/// Anteil des Preises, den ein Haendler beim Ankauf zahlt. **Die Spanne**:
|
||||
/// Ohne sie ist Handel ein Exploit oder ein No-op — kaufen und sofort
|
||||
/// zurueckverkaufen waere entweder gratis oder eine Geldmaschine.
|
||||
pub verkauf: f32,
|
||||
/// Wie stark `charisma` auf `price_modifier` wirkt (Ticket 02).
|
||||
pub charisma_preis: f32,
|
||||
// TODO(M5): `exp_base`, `exp_exponent` (Ticket 15) und `luck_factor`
|
||||
// (Ticket 16) — sie kommen mit ihren Lesern, nicht davor.
|
||||
}
|
||||
|
||||
impl Default for Balance {
|
||||
@@ -225,10 +231,54 @@ impl Default for Balance {
|
||||
resistance_cap: 0.8,
|
||||
hit_floor: 0.05,
|
||||
hit_ceiling: 0.95,
|
||||
verkauf: 0.4,
|
||||
charisma_preis: 1.5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Wahlmoeglichkeit im Gespraech. **Flach, kein Knotengraph** — was erst
|
||||
/// ein Graph koennte, ist Erzaehlung, und das Story-Modul steht unter
|
||||
/// *Out of scope*.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DialogOption {
|
||||
pub text: String,
|
||||
#[serde(default)]
|
||||
pub condition: Vec<Condition>,
|
||||
#[serde(default)]
|
||||
pub actions: Vec<Action>,
|
||||
}
|
||||
|
||||
/// Die **vierzehnte** Registry. Bewusst kein `creatures`-Eintrag mit
|
||||
/// `hostile = false`: Ein Haendler truege sonst `health`, `initiative`,
|
||||
/// `cooldowns`, `ai` und `exp_reward` als dauerhaft toten Ballast.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Npc {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default = "npc_symbol")]
|
||||
pub symbol: char,
|
||||
#[serde(default)]
|
||||
pub color: Option<String>,
|
||||
#[serde(default)]
|
||||
pub greeting: String,
|
||||
/// **Der Warenbestand ist eine Beutetabelle** — dieselben Gewichte,
|
||||
/// dieselbe Tabelle-in-Tabelle, dieselben Affixe. Kein neuer
|
||||
/// „Waren"-Begriff.
|
||||
#[serde(default)]
|
||||
pub shop: Option<String>,
|
||||
#[serde(default)]
|
||||
pub options: Vec<DialogOption>,
|
||||
// TODO(M5): `triggers` — erst wenn `triggers_for` NPCs einsammelt. Als
|
||||
// Feld ohne Leser waere es eine Zusage, die niemand einloest.
|
||||
#[serde(default)]
|
||||
pub overrides: Option<String>,
|
||||
}
|
||||
|
||||
fn npc_symbol() -> char {
|
||||
'n'
|
||||
}
|
||||
|
||||
/// Eine benannte Modifikatorliste, die an einem **einzelnen Exemplar** haengt —
|
||||
/// „Flammendes Eisenschwert" (Ticket 07).
|
||||
///
|
||||
@@ -323,6 +373,11 @@ pub struct Prefab {
|
||||
/// Ersetzt die Etagentabelle fuer die `l`-Plaetze dieses Raums (Ticket 16).
|
||||
#[serde(default)]
|
||||
pub loot: Option<String>,
|
||||
/// Wer auf den `n`-Plaetzen steht. Anders als bei `e` nennt das **Prefab**
|
||||
/// den NPC, nicht die Etage: NPCs skalieren nicht mit der Tiefe — ein
|
||||
/// Schmied auf Etage 5 ist derselbe wie auf Etage 1 (Ticket 10).
|
||||
#[serde(default)]
|
||||
pub npcs: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub overrides: Option<String>,
|
||||
}
|
||||
@@ -397,6 +452,7 @@ pub struct Registries {
|
||||
pub modifiers: HashMap<String, ModifierDef>,
|
||||
pub affixes: HashMap<String, Affix>,
|
||||
pub loot_tables: HashMap<String, LootTable>,
|
||||
pub npcs: HashMap<String, Npc>,
|
||||
/// Keine Registry — ein Block mit festem Schema, feldweise verschmolzen.
|
||||
pub balance: Balance,
|
||||
// TODO(M5+): `quests` (Ticket 08) und `scripts` (Ticket 13).
|
||||
|
||||
@@ -66,8 +66,8 @@ pub enum Target {
|
||||
/// Der Aktionskatalog. Zehn Eintraege sind entschieden (01, 08, 13, 14, 15);
|
||||
/// M3 braucht fuenf davon.
|
||||
///
|
||||
// TODO(M4+): `give_item`, `remove_item` (Inventar), `start_quest` (08),
|
||||
// `give_exp` (15), `run_script` (13).
|
||||
// TODO(M5+): `give_item`, `remove_item`, `start_quest` (08), `give_exp` (15),
|
||||
// `run_script` (13).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "snake_case")]
|
||||
pub enum Action {
|
||||
@@ -109,6 +109,9 @@ pub enum Action {
|
||||
/// Ticket 14: achte Aktion, weil Ticket 11 Flucht zu einer Faehigkeit
|
||||
/// gemacht hat und die etwas braucht, das sie ausfuehrt.
|
||||
Flee,
|
||||
/// Elfte Aktion (M4): Eine Dialogoption oeffnet den Laden. Als Aktion steht
|
||||
/// sie in derselben Liste wie `start_quest`, statt als Sonderfeld daneben.
|
||||
OpenShop,
|
||||
}
|
||||
|
||||
/// Die beiden Zustandsgroessen, die eine Aktion aendern darf. Bewusst **kein**
|
||||
@@ -127,7 +130,7 @@ impl Action {
|
||||
| Action::ChangeAttribute { target, .. }
|
||||
| Action::ApplyModifier { target, .. }
|
||||
| Action::RemoveModifier { target, .. } => *target,
|
||||
Action::Flee => Target::Zelf,
|
||||
Action::Flee | Action::OpenShop => Target::Zelf,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ pub struct Modifier {
|
||||
pub flat: f32,
|
||||
#[serde(default)]
|
||||
pub percent: f32,
|
||||
// TODO(M4): `target` fuer Auren (`all_enemies`) und `condition` fuer
|
||||
// TODO(M5): `target` fuer Auren (`all_enemies`) und `condition` fuer
|
||||
// bedingte Modifikatoren („+5 gegen Untote", Ticket 08). Beide
|
||||
// brauchen `Game::attribute`s `against`-Parameter, der bereits
|
||||
// in der Signatur steht.
|
||||
|
||||
@@ -13,7 +13,7 @@ pub enum Direction {
|
||||
|
||||
/// Ticket 11 hat zwoelf Commands festgelegt; M1 braucht zwei davon.
|
||||
///
|
||||
// TODO(M5+): `Talk`, `Buy`, `Sell` — und `AssignPoints` aus Ticket 15.
|
||||
// TODO(M5+): `AssignPoints` aus Ticket 15.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Command {
|
||||
Move(Direction),
|
||||
@@ -27,6 +27,15 @@ pub enum Command {
|
||||
Equip(usize),
|
||||
Unequip(String),
|
||||
Drop(usize),
|
||||
/// Wirkt auf das Feld, **auf dem der Held steht** — dieselbe Regel wie
|
||||
/// `PickUp` und `Descend`. Ein NPC blockiert nicht: Er steht in einem Laden,
|
||||
/// nicht in einer Tuer.
|
||||
Talk,
|
||||
/// Index in `Game::dialog_options` — die **gefilterte** Liste, damit der
|
||||
/// Screen keine Bedingungen auswerten muss.
|
||||
Choose(usize),
|
||||
Buy(usize),
|
||||
Sell(usize),
|
||||
/// Der normale Angriff ist **keine eigene Variante**, sondern
|
||||
/// `UseAbility("core:attack")` — Faehigkeiten sind seit Ticket 02 eine
|
||||
/// Registry, also gehoert er dorthin, und ein Mod kann ihn ersetzen.
|
||||
|
||||
@@ -20,11 +20,30 @@ pub struct ItemInstance {
|
||||
pub def: String,
|
||||
#[serde(default)]
|
||||
pub affixes: Vec<String>,
|
||||
/// Stapelmenge. Stand bis M4 nicht hier — der Beutezieher schob fuer
|
||||
/// „50-120 Gold" bis zu 120 einzelne Eintraege ins Inventar.
|
||||
#[serde(default = "one")]
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
fn one() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
impl ItemInstance {
|
||||
pub fn new(def: impl Into<String>) -> Self {
|
||||
Self { def: def.into(), affixes: Vec::new() }
|
||||
Self { def: def.into(), affixes: Vec::new(), count: 1 }
|
||||
}
|
||||
|
||||
pub fn stack(def: impl Into<String>, count: u32) -> Self {
|
||||
Self { def: def.into(), affixes: Vec::new(), count }
|
||||
}
|
||||
|
||||
/// Die Stapelregel aus Ticket 07: gleiche `def` **und** gleiche `affixes`.
|
||||
/// Dass ein Trank mit Zufallsaffix nicht mehr stapelt, ist die logische
|
||||
/// Folge — und das Problem dessen, der ihn so gebaut hat.
|
||||
pub fn stacks_with(&self, other: &ItemInstance) -> bool {
|
||||
self.def == other.def && self.affixes == other.affixes
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,16 @@ impl Rect {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein NPC auf der Etage. Sein Warenkorb wird **einmal bei der Erzeugung**
|
||||
/// gezogen und liegt danach fest — sonst wuerfelte jedes Oeffnen des Ladens neu
|
||||
/// und der Spieler koennte sich das Angebot herbeischliessen (M4).
|
||||
#[derive(Debug)]
|
||||
pub struct NpcInstance {
|
||||
pub pos: (u16, u16),
|
||||
pub def: String,
|
||||
pub stock: Vec<super::creature::ItemInstance>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Room {
|
||||
/// Fuer `room_entered` und die Trigger am Prefab (Ticket 08/10).
|
||||
@@ -57,6 +67,9 @@ pub struct Room {
|
||||
/// Die `l`-Plaetze. Ticket 16: erst beim **Aufheben** aufgeloest, deshalb
|
||||
/// stehen hier Positionen und keine Gegenstaende.
|
||||
pub loot: Vec<(u16, u16)>,
|
||||
/// Die `n`-Plaetze, schon aufgeloest. Anders als bei `e` nennt das Prefab
|
||||
/// den NPC — ein Schmied skaliert nicht mit der Tiefe.
|
||||
pub npcs: Vec<NpcInstance>,
|
||||
pub cleared: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@ pub fn generate(def: &FloorDef, reg: &Registries, rng: &mut Rng) -> Floor {
|
||||
for p in &placed {
|
||||
let mut enemy_spawns = Vec::new();
|
||||
let mut loot = Vec::new();
|
||||
let mut npc_plaetze = Vec::new();
|
||||
|
||||
for y in 0..p.stamp.h {
|
||||
for x in 0..p.stamp.w {
|
||||
@@ -124,6 +125,7 @@ pub fn generate(def: &FloorDef, reg: &Registries, rng: &mut Rng) -> Floor {
|
||||
match c {
|
||||
'e' => enemy_spawns.push((gx, gy)),
|
||||
'l' => loot.push((gx, gy)),
|
||||
'n' => npc_plaetze.push((gx, gy)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -152,6 +154,23 @@ pub fn generate(def: &FloorDef, reg: &Registries, rng: &mut Rng) -> Floor {
|
||||
.or_else(|| def.loot.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Der i-te `n`-Platz bekommt den i-ten Eintrag aus `prefab.npcs` —
|
||||
// Positionsbindung statt eines zweiten Markers je NPC.
|
||||
let npcs = npc_plaetze
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, pos)| {
|
||||
let id = reg.prefabs.get(&p.prefab)?.npcs.get(i)?.clone();
|
||||
let stock = reg
|
||||
.npcs
|
||||
.get(&id)
|
||||
.and_then(|n| n.shop.as_deref())
|
||||
.map(|t| crate::game::loot::roll(t, reg, rng))
|
||||
.unwrap_or_default();
|
||||
Some(super::floor::NpcInstance { pos, def: id, stock })
|
||||
})
|
||||
.collect();
|
||||
|
||||
rooms.push(Room {
|
||||
prefab: p.prefab.clone(),
|
||||
rect: p.rect,
|
||||
@@ -159,6 +178,7 @@ pub fn generate(def: &FloorDef, reg: &Registries, rng: &mut Rng) -> Floor {
|
||||
enemies,
|
||||
loot_table,
|
||||
loot,
|
||||
npcs,
|
||||
cleared: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -87,9 +87,24 @@ fn emit(
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
// Ein Eintrag mit `count = "50-120"` liefert **einen** Stapel, nicht 120
|
||||
// Eintraege — die Stapelregel aus Ticket 07 wird hier zum ersten Mal
|
||||
// gebraucht (Befund aus M4).
|
||||
let (lo, hi) = parse_range(entry.count.as_ref());
|
||||
for _ in 0..rng.range(lo, hi) {
|
||||
out.push(ItemInstance { def: item.clone(), affixes: affixes.clone() });
|
||||
let menge = rng.range(lo, hi);
|
||||
if menge == 0 {
|
||||
return;
|
||||
}
|
||||
let stapelbar = reg.items.get(item).is_some_and(|d| d.stackable);
|
||||
|
||||
if stapelbar {
|
||||
out.push(ItemInstance { def: item.clone(), affixes, count: menge });
|
||||
} else {
|
||||
// Nicht stapelbar: jedes Exemplar einzeln, damit Affixe je Stueck
|
||||
// unterschiedlich sein koennten.
|
||||
for _ in 0..menge {
|
||||
out.push(ItemInstance { def: item.clone(), affixes: affixes.clone(), count: 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
290
src/game/mod.rs
290
src/game/mod.rs
@@ -48,7 +48,12 @@ pub struct Game {
|
||||
pub last: String,
|
||||
/// Kampflog, die letzten Zeilen.
|
||||
pub journal: Vec<String>,
|
||||
// TODO(M4): `quests`, `current_exp`, `unspent_points` (Ticket 15 — an
|
||||
/// Mit wem gerade gesprochen wird — Index in `Room.npcs`.
|
||||
pub talking_to: Option<usize>,
|
||||
/// Absicht aus `open_shop`. Die Schleife liest sie und schiebt den Screen;
|
||||
/// `game/` kennt keine Screens.
|
||||
pub open_shop: bool,
|
||||
// TODO(M5): `quests`, `current_exp`, `unspent_points` (Ticket 15 — an
|
||||
// `Game`, nicht am `Creature`).
|
||||
}
|
||||
|
||||
@@ -73,6 +78,8 @@ impl Game {
|
||||
events: std::collections::VecDeque::new(),
|
||||
last: String::from("—"),
|
||||
journal: Vec::new(),
|
||||
talking_to: None,
|
||||
open_shop: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -372,6 +379,10 @@ impl Game {
|
||||
self.creature_mut(victim).modifiers.retain(|m| m.id != *modifier);
|
||||
}
|
||||
|
||||
// Der Laden ist reine UI-Folge: Die Aktion setzt eine Absicht,
|
||||
// die Schleife schiebt den Screen. `game/` kennt ratatui nicht.
|
||||
Action::OpenShop => self.open_shop = true,
|
||||
|
||||
Action::Flee => {
|
||||
let own = self.attribute(source, Attribute::Dexterity, None, reg);
|
||||
// Gerechnete Werte, nicht Basiswerte — sonst ignoriert die
|
||||
@@ -556,10 +567,85 @@ impl Game {
|
||||
Command::Unequip(slot) => {
|
||||
(!self.player.equipment.contains_key(slot)).then_some("Dort ist nichts")
|
||||
}
|
||||
Command::Talk if in_combat => Some("Nicht im Kampf"),
|
||||
Command::Talk => {
|
||||
let (x, y) = self.player_pos;
|
||||
self.npc_here(x, y).is_none().then_some("Hier steht niemand")
|
||||
}
|
||||
Command::Choose(i) => {
|
||||
(*i >= self.dialog_options(reg).len()).then_some("Keine solche Antwort")
|
||||
}
|
||||
Command::Buy(i) => {
|
||||
let Some(stock) = self.shop_stock() else {
|
||||
return Some("Kein Laden offen");
|
||||
};
|
||||
let Some(inst) = stock.get(*i) else { return Some("Nicht im Angebot") };
|
||||
let preis = self.buy_price(inst, reg);
|
||||
(self.gold() < preis).then_some("Zu wenig Gold")
|
||||
}
|
||||
Command::Sell(i) if *i >= self.player.inventory.len() => {
|
||||
Some("Kein solcher Gegenstand")
|
||||
}
|
||||
// Sein eigenes Gold zurueckzuverkaufen ergibt keinen Sinn.
|
||||
Command::Sell(i) => (self.player.inventory[*i].def == "core:gold")
|
||||
.then_some("Gold verkauft man nicht"),
|
||||
Command::Wait => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn npc_here(&self, x: u16, y: u16) -> Option<usize> {
|
||||
let room = self.floor.player_room?;
|
||||
self.floor.rooms[room].npcs.iter().position(|n| n.pos == (x, y))
|
||||
}
|
||||
|
||||
fn npc_instance(&self) -> Option<&crate::game::floor::NpcInstance> {
|
||||
self.floor.rooms[self.floor.player_room?].npcs.get(self.talking_to?)
|
||||
}
|
||||
|
||||
/// Die **erfuellbaren** Optionen des laufenden Gesprraechs. Filtern gehoert
|
||||
/// hierhin und nicht in den Screen — sonst wertete die Anzeige Bedingungen
|
||||
/// aus (Ticket 11).
|
||||
pub fn dialog_options<'r>(&self, reg: &'r Registries) -> Vec<&'r crate::data::DialogOption> {
|
||||
let Some(npc) = self.talking_npc(reg) else { return Vec::new() };
|
||||
npc.options
|
||||
.iter()
|
||||
.filter(|o| self.conditions_hold(&o.condition, CreatureRef::Player, None, reg))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn talking_npc<'r>(&self, reg: &'r Registries) -> Option<&'r crate::data::Npc> {
|
||||
reg.npcs.get(&self.npc_instance()?.def)
|
||||
}
|
||||
|
||||
pub fn shop_stock(&self) -> Option<&[ItemInstance]> {
|
||||
Some(&self.npc_instance()?.stock)
|
||||
}
|
||||
|
||||
/// `kauf = item_price × (1 − price_modifier/100)`.
|
||||
///
|
||||
/// Die Charisma-Formel lebt **hier**, nicht in `Game::attribute` — dort
|
||||
/// waere `price_modifier` das einzige Attribut mit einem Sonderfall, und ein
|
||||
/// generischer Pfad mit einer Ausnahme ist die Stelle, an der spaeter jemand
|
||||
/// raet.
|
||||
pub fn price_modifier(&self, reg: &Registries) -> f32 {
|
||||
let p = CreatureRef::Player;
|
||||
self.attribute(p, Attribute::PriceModifier, None, reg)
|
||||
+ self.attribute(p, Attribute::Charisma, None, reg) * reg.balance.charisma_preis
|
||||
}
|
||||
|
||||
pub fn buy_price(&self, inst: &ItemInstance, reg: &Registries) -> u32 {
|
||||
let roh = self.item_price(inst, reg) as f32;
|
||||
(roh * (1.0 - self.price_modifier(reg) / 100.0)).max(1.0).round() as u32
|
||||
}
|
||||
|
||||
/// `verkauf = item_price × VERKAUF × (1 + price_modifier/100)` — die
|
||||
/// **Spanne** verhindert die Geldmaschine, deshalb braucht der Haendler
|
||||
/// keinen Goldvorrat.
|
||||
pub fn sell_price(&self, inst: &ItemInstance, reg: &Registries) -> u32 {
|
||||
let roh = self.item_price(inst, reg) as f32 * reg.balance.verkauf;
|
||||
(roh * (1.0 + self.price_modifier(reg) / 100.0)).max(1.0).round() as u32
|
||||
}
|
||||
|
||||
/// Alle Faehigkeiten eines Wesens, in stabiler Reihenfolge.
|
||||
///
|
||||
/// `core:attack` und `core:flee` sind **jedem Wesen implizit gewaehrt**
|
||||
@@ -646,6 +732,10 @@ impl Game {
|
||||
Command::UseItem(i) => self.use_item(*i, reg),
|
||||
Command::Equip(i) => self.equip(*i, reg),
|
||||
Command::Unequip(slot) => self.unequip(&slot.clone()),
|
||||
Command::Talk => self.talk(reg),
|
||||
Command::Choose(i) => self.choose(*i, reg),
|
||||
Command::Buy(i) => self.buy(*i, reg),
|
||||
Command::Sell(i) => self.sell(*i, reg),
|
||||
Command::Drop(i) => {
|
||||
let inst = self.player.inventory.remove(*i);
|
||||
let name = self.item_label(&inst, reg);
|
||||
@@ -677,11 +767,74 @@ impl Game {
|
||||
}
|
||||
|
||||
let namen: Vec<String> = beute.iter().map(|i| self.item_label(i, reg)).collect();
|
||||
self.player.inventory.extend(beute);
|
||||
for inst in beute {
|
||||
self.give(inst, reg);
|
||||
}
|
||||
self.log(format!("Aufgehoben: {}", namen.join(", ")));
|
||||
self.events.push_back(Event::new(EventKind::ItemPickedUp, CreatureRef::Player));
|
||||
}
|
||||
|
||||
fn talk(&mut self, reg: &Registries) {
|
||||
let (x, y) = self.player_pos;
|
||||
let Some(i) = self.npc_here(x, y) else { return };
|
||||
let room = self.floor.rooms.get(self.floor.player_room.unwrap()).unwrap();
|
||||
let id = room.npcs[i].def.clone();
|
||||
self.talking_to = Some(i);
|
||||
|
||||
if let Some(npc) = reg.npcs.get(&id)
|
||||
&& !npc.greeting.is_empty()
|
||||
{
|
||||
self.log(format!("{}: {}", npc.name, npc.greeting));
|
||||
}
|
||||
}
|
||||
|
||||
fn choose(&mut self, index: usize, reg: &Registries) {
|
||||
let Some(option) = self.dialog_options(reg).get(index).copied() else { return };
|
||||
let text = option.text.clone();
|
||||
let actions = option.actions.clone();
|
||||
self.log(format!("> {text}"));
|
||||
for action in &actions {
|
||||
self.apply(action, CreatureRef::Player, CreatureRef::Player, reg);
|
||||
}
|
||||
}
|
||||
|
||||
fn buy(&mut self, index: usize, reg: &Registries) {
|
||||
let Some(stock) = self.shop_stock() else { return };
|
||||
let inst = stock[index].clone();
|
||||
let preis = self.buy_price(&inst, reg);
|
||||
|
||||
// Wird erst genommen, dann gegeben: Ein Fehlschlag hier waere ein
|
||||
// Gegenstand aus dem Nichts. `rejects` hat die Deckung schon geprueft.
|
||||
if !self.take("core:gold", preis) {
|
||||
return;
|
||||
}
|
||||
let name = self.item_label(&inst, reg);
|
||||
self.give(inst, reg);
|
||||
let room = self.floor.player_room.unwrap();
|
||||
self.floor.rooms[room].npcs[self.talking_to.unwrap()].stock.remove(index);
|
||||
self.log(format!("{name} gekauft fuer {preis} Gold"));
|
||||
}
|
||||
|
||||
fn sell(&mut self, index: usize, reg: &Registries) {
|
||||
// Ein Stapel wird stueckweise verkauft — sonst gaebe es keinen Weg,
|
||||
// fuenf von fuenfzig Traenken loszuwerden.
|
||||
let inst = ItemInstance { count: 1, ..self.player.inventory[index].clone() };
|
||||
let preis = self.sell_price(&inst, reg);
|
||||
let name = self.item_label(&inst, reg);
|
||||
|
||||
// Ueber den Index, nicht ueber `take`: Zwei Traenke mit verschiedenen
|
||||
// Affixen teilen sich die `def`, und `take` naehme den erstbesten.
|
||||
let slot = &mut self.player.inventory[index];
|
||||
slot.count -= 1;
|
||||
if slot.count == 0 {
|
||||
self.player.inventory.remove(index);
|
||||
}
|
||||
self.give(ItemInstance::stack("core:gold", preis), reg);
|
||||
// Der Haendler hat kein Gold-Konto: Die Spanne, nicht ein Vorrat,
|
||||
// begrenzt den Handel.
|
||||
self.log(format!("{name} verkauft fuer {preis} Gold"));
|
||||
}
|
||||
|
||||
fn use_item(&mut self, index: usize, reg: &Registries) {
|
||||
let inst = self.player.inventory[index].clone();
|
||||
let Some(def) = reg.items.get(&inst.def) else { return };
|
||||
@@ -724,6 +877,49 @@ impl Game {
|
||||
}
|
||||
}
|
||||
|
||||
/// Legt einen Stapel ins Inventar und **verschmilzt ihn**, wenn die
|
||||
/// Stapelregel aus Ticket 07 hält: `stackable` und gleiche `def` und
|
||||
/// gleiche `affixes`.
|
||||
pub fn give(&mut self, inst: ItemInstance, reg: &Registries) {
|
||||
let stapelbar = reg.items.get(&inst.def).is_some_and(|d| d.stackable);
|
||||
if stapelbar
|
||||
&& let Some(vorhanden) =
|
||||
self.player.inventory.iter_mut().find(|i| i.stacks_with(&inst))
|
||||
{
|
||||
vorhanden.count += inst.count;
|
||||
return;
|
||||
}
|
||||
self.player.inventory.push(inst);
|
||||
}
|
||||
|
||||
/// Nimmt `menge` Stueck eines Gegenstands aus dem Inventar. Gibt zurueck,
|
||||
/// ob genug da war — Gold ist ein gewoehnlicher Gegenstand (Ticket 09),
|
||||
/// also laeuft Bezahlen hier durch.
|
||||
pub fn take(&mut self, def: &str, menge: u32) -> bool {
|
||||
if self.count_of(def) < menge {
|
||||
return false;
|
||||
}
|
||||
let mut rest = menge;
|
||||
self.player.inventory.retain_mut(|i| {
|
||||
if rest == 0 || i.def != def {
|
||||
return true;
|
||||
}
|
||||
let weg = rest.min(i.count);
|
||||
i.count -= weg;
|
||||
rest -= weg;
|
||||
i.count > 0
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
pub fn count_of(&self, def: &str) -> u32 {
|
||||
self.player.inventory.iter().filter(|i| i.def == def).map(|i| i.count).sum()
|
||||
}
|
||||
|
||||
pub fn gold(&self) -> u32 {
|
||||
self.count_of("core:gold")
|
||||
}
|
||||
|
||||
/// Der Anzeigename eines Exemplars: Affixe werden vorangestellt —
|
||||
/// „Flammendes Eisenschwert" (Ticket 07).
|
||||
pub fn item_label(&self, inst: &ItemInstance, reg: &Registries) -> String {
|
||||
@@ -734,7 +930,8 @@ impl Game {
|
||||
.filter_map(|a| reg.affixes.get(a).map(|d| d.name.as_str()))
|
||||
.collect();
|
||||
teile.push(base);
|
||||
teile.join(" ")
|
||||
let name = teile.join(" ");
|
||||
if inst.count > 1 { format!("{name} ×{}", inst.count) } else { name }
|
||||
}
|
||||
|
||||
/// Preis eines Exemplars: `base_price × Π price_factor` der Affixe
|
||||
@@ -1275,4 +1472,91 @@ mod tests {
|
||||
assert!(matches!(game.execute(Command::Wait, ®), Outcome::TurnPassed));
|
||||
assert_eq!(game.turn, 1);
|
||||
}
|
||||
|
||||
/// Ticket 07 beschrieb die Stapelregel, aber niemand hatte sie umgesetzt —
|
||||
/// der Beutezieher schob fuer "50-120 Gold" bis zu 120 Eintraege ins
|
||||
/// Inventar. Verschiedene Affixe stapeln bewusst **nicht**.
|
||||
#[test]
|
||||
fn gleiche_gegenstaende_stapeln_verschiedene_affixe_nicht() {
|
||||
let (mut game, reg) = zwergenkrieger();
|
||||
game.give(ItemInstance::stack("core:gold", 30), ®);
|
||||
game.give(ItemInstance::stack("core:gold", 12), ®);
|
||||
assert_eq!(game.gold(), 42);
|
||||
assert_eq!(game.player.inventory.len(), 1, "Gold muss ein Stapel sein");
|
||||
|
||||
let mut scharf = ItemInstance::new("core:health_potion");
|
||||
scharf.affixes = vec![String::from("core:sharp")];
|
||||
game.give(ItemInstance::new("core:health_potion"), ®);
|
||||
game.give(scharf, ®);
|
||||
assert_eq!(
|
||||
game.player.inventory.iter().filter(|i| i.def == "core:health_potion").count(),
|
||||
2,
|
||||
"verschiedene Affixe duerfen nicht stapeln"
|
||||
);
|
||||
|
||||
assert!(game.take("core:gold", 40));
|
||||
assert_eq!(game.gold(), 2);
|
||||
assert!(!game.take("core:gold", 5), "zu wenig muss fehlschlagen");
|
||||
assert_eq!(game.gold(), 2, "der Fehlschlag darf nichts abziehen");
|
||||
}
|
||||
|
||||
/// Die **Spanne** ist der ganze Grund, warum der Haendler kein Goldkonto
|
||||
/// braucht: Kaufen und sofort zurueckverkaufen muss Verlust machen.
|
||||
#[test]
|
||||
fn handel_kostet_die_spanne() {
|
||||
let (game, reg) = zwergenkrieger();
|
||||
let schwert = ItemInstance::new("core:iron_sword");
|
||||
let kauf = game.buy_price(&schwert, ®);
|
||||
let verkauf = game.sell_price(&schwert, ®);
|
||||
assert!(verkauf < kauf, "Ankauf {verkauf} muss unter Verkauf {kauf} liegen");
|
||||
}
|
||||
|
||||
/// Der ganze Weg: ansprechen, Laden oeffnen, kaufen. Prueft nebenbei, dass
|
||||
/// der Warenkorb aus der Erzeugung stammt und nicht beim Oeffnen entsteht.
|
||||
#[test]
|
||||
fn kaufen_zieht_gold_ab_und_leert_das_regal() {
|
||||
let (mut game, reg) = zwergenkrieger();
|
||||
|
||||
// Den Schmied auf irgendeiner Etage suchen — welches Prefab wo landet,
|
||||
// entscheidet der Wurf.
|
||||
let mut gefunden = None;
|
||||
for _ in 0..40 {
|
||||
if let Some((r, pos)) = game
|
||||
.floor
|
||||
.rooms
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(r, room)| room.npcs.first().map(|n| (r, n.pos)))
|
||||
{
|
||||
gefunden = Some((r, pos));
|
||||
break;
|
||||
}
|
||||
let def = reg.floor_number(1).expect("Etage 1");
|
||||
game.floor = generate::generate(def, ®, &mut game.rng);
|
||||
}
|
||||
let (raum, pos) = gefunden.expect("mindestens ein Marktraum in 40 Etagen");
|
||||
|
||||
game.player_pos = pos;
|
||||
game.floor.player_room = Some(raum);
|
||||
assert!(matches!(game.execute(Command::Talk, ®), Outcome::TurnPassed));
|
||||
assert_eq!(game.talking_to, Some(0));
|
||||
|
||||
let laden = game.dialog_options(®);
|
||||
assert!(laden.len() >= 3, "die bedingte Option faellt raus, drei bleiben");
|
||||
assert!(matches!(game.execute(Command::Choose(0), ®), Outcome::TurnPassed));
|
||||
assert!(game.open_shop, "die erste Option oeffnet den Laden");
|
||||
|
||||
let ware = game.shop_stock().expect("Warenkorb").to_vec();
|
||||
assert!(!ware.is_empty(), "der Korb wird bei der Erzeugung gezogen");
|
||||
|
||||
// Ohne Gold ist jeder Kauf eine Abweisung, mit Gold geht er durch.
|
||||
assert!(matches!(game.execute(Command::Buy(0), ®), Outcome::Rejected(_)));
|
||||
let preis = game.buy_price(&ware[0], ®);
|
||||
game.give(ItemInstance::stack("core:gold", preis), ®);
|
||||
assert!(matches!(game.execute(Command::Buy(0), ®), Outcome::TurnPassed));
|
||||
|
||||
assert_eq!(game.gold(), 0, "der Preis muss abgezogen sein");
|
||||
assert_eq!(game.shop_stock().expect("Korb").len(), ware.len() - 1);
|
||||
assert!(game.player.inventory.iter().any(|i| i.def == ware[0].def));
|
||||
}
|
||||
}
|
||||
|
||||
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).
|
||||
|
||||
@@ -7,11 +7,15 @@ use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
use crate::data::Registries;
|
||||
use crate::game::{Floor, Tile};
|
||||
|
||||
pub struct GridWidget<'a> {
|
||||
pub floor: &'a Floor,
|
||||
pub player: (u16, u16),
|
||||
/// Nur fuer die NPC-Symbole. Anders als Beutehaufen tragen NPCs ihr eigenes
|
||||
/// Zeichen — wer im Laden steht, soll man vor dem Ansprechen sehen.
|
||||
pub reg: &'a Registries,
|
||||
}
|
||||
|
||||
impl Widget for GridWidget<'_> {
|
||||
@@ -53,6 +57,24 @@ impl Widget for GridWidget<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
for room in &self.floor.rooms {
|
||||
for npc in &room.npcs {
|
||||
let (nx, ny) = npc.pos;
|
||||
if nx < ox || ny < oy {
|
||||
continue;
|
||||
}
|
||||
let (px, py) = (area.x + nx - ox, area.y + ny - oy);
|
||||
if px < area.right() && py < area.bottom() {
|
||||
let def = self.reg.npcs.get(&npc.def);
|
||||
let symbol = def.map_or('n', |n| n.symbol);
|
||||
let color = super::theme::color(def.and_then(|n| n.color.as_deref()));
|
||||
buf[(px, py)]
|
||||
.set_symbol(&symbol.to_string())
|
||||
.set_style(Style::new().fg(color).add_modifier(Modifier::BOLD));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (sx, sy) = (area.x + self.player.0 - ox, area.y + self.player.1 - oy);
|
||||
if sx < area.right() && sy < area.bottom() {
|
||||
buf[(sx, sy)]
|
||||
|
||||
@@ -25,8 +25,20 @@ pub enum Screen {
|
||||
/// Meldungszeile im Hauptmenue: Ein Abstiegs-Roguelike lebt davon, dass der
|
||||
/// Lauf am Ende *erzaehlt* wird (Ticket 14).
|
||||
GameOver,
|
||||
// TODO(M3+): `CharacterCreation`, `Combat`, `Inventory`, `QuestLog`,
|
||||
// `Shop`, `Dialog`, `Confirm` — elf sind es am Ende (11/14).
|
||||
/// **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
|
||||
@@ -78,6 +90,7 @@ impl Screen {
|
||||
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 }),
|
||||
@@ -165,6 +178,65 @@ impl Screen {
|
||||
_ => 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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user