feat: m4 teil 1 — beute, inventar und affixe
- data/: [[loot_tables]] und [[affixes]] als Registries; LootEntry mit guaranteed, nothing, table-in-table, count und affixes - game/loot.rs: EINE Ziehmaschinerie fuer Gegenstaende und Affixe, Tiefendeckel gegen wechselseitigen Bezug - PickUp zieht erst beim Aufheben (Ticket 16) — moeglich nur, weil alle Haufen dasselbe Symbol zeigen - UseItem/Equip/Unequip/Drop, Inventar-Screen als Registry-Schleife - Affixe im Attributpfad, in der Resistenz und in der Trigger-Suche; item_label stellt Affixnamen voran, item_price multipliziert Faktoren - Room haelt eine loot_table (nicht je Haufen eine), Prefab-loot ueberschreibt die Etagentabelle 53 Tests. Offen aus M4: Handel.
This commit is contained in:
46
mods/core/affixes.toml
Normal file
46
mods/core/affixes.toml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Affixe (Ticket 07/16): eine benannte Modifikatorliste, die an einem EINZELNEN
|
||||||
|
# Exemplar haengt. Strukturell dasselbe wie eine Charakteroption — der
|
||||||
|
# Unterschied ist, woran sie haengt.
|
||||||
|
#
|
||||||
|
# Abstufungen sind EIGENE EINTRAEGE, keine Zahl am Exemplar: Eine Staerke je
|
||||||
|
# Exemplar haette Save-Format (ID-Liste), Preisformel und Stapelregel auf einmal
|
||||||
|
# gekostet. Mit Stufen kann der Modder seine Abstufungen benennen.
|
||||||
|
|
||||||
|
[[affixes]]
|
||||||
|
id = "sharp"
|
||||||
|
name = "Scharfes"
|
||||||
|
price_factor = 1.3
|
||||||
|
modifiers = [ { attribute = "damage", flat = 2 } ]
|
||||||
|
|
||||||
|
[[affixes]]
|
||||||
|
id = "flaming"
|
||||||
|
name = "Flammendes"
|
||||||
|
price_factor = 1.6
|
||||||
|
modifiers = [ { attribute = "damage", flat = 3 } ]
|
||||||
|
triggers = [
|
||||||
|
{ on = "attack_hit", chance = 0.25,
|
||||||
|
actions = [ { action = "damage", amount = 4, damage_type = "fire", target = "target" } ] },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[affixes]]
|
||||||
|
id = "greater_flaming"
|
||||||
|
name = "Stark flammendes"
|
||||||
|
price_factor = 2.4
|
||||||
|
modifiers = [ { attribute = "damage", flat = 6 } ]
|
||||||
|
triggers = [
|
||||||
|
{ on = "attack_hit", chance = 0.4,
|
||||||
|
actions = [ { action = "damage", amount = 9, damage_type = "fire", target = "target" } ] },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[affixes]]
|
||||||
|
id = "sturdy"
|
||||||
|
name = "Robuste"
|
||||||
|
price_factor = 1.4
|
||||||
|
modifiers = [ { attribute = "max_health", flat = 12 } ]
|
||||||
|
|
||||||
|
[[affixes]]
|
||||||
|
id = "warding"
|
||||||
|
name = "Schirmende"
|
||||||
|
price_factor = 1.8
|
||||||
|
modifiers = [ { attribute = "resistance", damage_type = "fire", flat = 20 },
|
||||||
|
{ attribute = "resistance", damage_type = "ice", flat = 20 } ]
|
||||||
@@ -16,6 +16,7 @@ rooms = 7
|
|||||||
required = ["core:entrance", "core:stairs", "core:merchant"]
|
required = ["core:entrance", "core:stairs", "core:merchant"]
|
||||||
prefabs = [ { tag = "core:normal", weight = 10 } ]
|
prefabs = [ { tag = "core:normal", weight = 10 } ]
|
||||||
creatures = [ { id = "core:rat", weight = 10 } ]
|
creatures = [ { id = "core:rat", weight = 10 } ]
|
||||||
|
loot = "core:floor_1_table"
|
||||||
|
|
||||||
[[floors]]
|
[[floors]]
|
||||||
id = "floor_2"
|
id = "floor_2"
|
||||||
@@ -26,6 +27,7 @@ required = ["core:entrance", "core:stairs", "core:merchant"]
|
|||||||
prefabs = [ { tag = "core:normal", weight = 10 },
|
prefabs = [ { tag = "core:normal", weight = 10 },
|
||||||
{ tag = "core:treasure", weight = 2 } ]
|
{ tag = "core:treasure", weight = 2 } ]
|
||||||
creatures = [ { id = "core:rat", weight = 6 }, { id = "core:skeleton", weight = 6 } ]
|
creatures = [ { id = "core:rat", weight = 6 }, { id = "core:skeleton", weight = 6 } ]
|
||||||
|
loot = "core:floor_1_table"
|
||||||
|
|
||||||
[[floors]]
|
[[floors]]
|
||||||
id = "floor_3"
|
id = "floor_3"
|
||||||
@@ -36,6 +38,7 @@ required = ["core:entrance", "core:stairs", "core:merchant"]
|
|||||||
prefabs = [ { tag = "core:normal", weight = 10 },
|
prefabs = [ { tag = "core:normal", weight = 10 },
|
||||||
{ tag = "core:treasure", weight = 3 } ]
|
{ tag = "core:treasure", weight = 3 } ]
|
||||||
creatures = [ { id = "core:rat", weight = 3 }, { id = "core:skeleton", weight = 8 }, { id = "core:ghoul", weight = 3 } ]
|
creatures = [ { id = "core:rat", weight = 3 }, { id = "core:skeleton", weight = 8 }, { id = "core:ghoul", weight = 3 } ]
|
||||||
|
loot = "core:floor_3_table"
|
||||||
|
|
||||||
[[floors]]
|
[[floors]]
|
||||||
id = "floor_4"
|
id = "floor_4"
|
||||||
@@ -46,6 +49,7 @@ required = ["core:entrance", "core:stairs", "core:merchant"]
|
|||||||
prefabs = [ { tag = "core:normal", weight = 10 },
|
prefabs = [ { tag = "core:normal", weight = 10 },
|
||||||
{ tag = "core:treasure", weight = 3 } ]
|
{ tag = "core:treasure", weight = 3 } ]
|
||||||
creatures = [ { id = "core:skeleton", weight = 6 }, { id = "core:ghoul", weight = 8 } ]
|
creatures = [ { id = "core:skeleton", weight = 6 }, { id = "core:ghoul", weight = 8 } ]
|
||||||
|
loot = "core:floor_3_table"
|
||||||
|
|
||||||
[[floors]]
|
[[floors]]
|
||||||
id = "floor_5"
|
id = "floor_5"
|
||||||
@@ -56,3 +60,4 @@ required = ["core:entrance", "core:stairs", "core:merchant"]
|
|||||||
prefabs = [ { tag = "core:normal", weight = 10 },
|
prefabs = [ { tag = "core:normal", weight = 10 },
|
||||||
{ tag = "core:treasure", weight = 4 } ]
|
{ tag = "core:treasure", weight = 4 } ]
|
||||||
creatures = [ { id = "core:ghoul", weight = 8 }, { id = "core:bone_lord", weight = 2 } ]
|
creatures = [ { id = "core:ghoul", weight = 8 }, { id = "core:bone_lord", weight = 2 } ]
|
||||||
|
loot = "core:floor_5_table"
|
||||||
|
|||||||
@@ -53,3 +53,13 @@ triggers = [
|
|||||||
when = { damage_type = "fire" },
|
when = { damage_type = "fire" },
|
||||||
actions = [ { action = "damage", amount = 3, damage_type = "fire", target = "target" } ] },
|
actions = [ { action = "damage", amount = 3, damage_type = "fire", target = "target" } ] },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[items]]
|
||||||
|
id = "bone_blade"
|
||||||
|
name = "Knochenklinge"
|
||||||
|
base_price = 210
|
||||||
|
slot = "core:hand"
|
||||||
|
symbol = "/"
|
||||||
|
color = "magenta"
|
||||||
|
modifiers = [ { attribute = "damage", flat = 11 },
|
||||||
|
{ attribute = "critical_chance", flat = 8 } ]
|
||||||
|
|||||||
88
mods/core/loot.toml
Normal file
88
mods/core/loot.toml
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Beutetabellen (Ticket 16). EINE Maschinerie fuer Gegenstaende und Affixe:
|
||||||
|
# dieselben Gewichte, dieselbe Tabelle-in-Tabelle, dieselben Nieten.
|
||||||
|
#
|
||||||
|
# Gewichte statt Prozent — Prozente muessten sich auf 100 summieren, und ein
|
||||||
|
# Eintrag, den ein Mod hinzufuegt, macht die Summe kaputt.
|
||||||
|
#
|
||||||
|
# Der MOD-EINHAENGEPUNKT ist Tabelle-in-Tabelle: Ein Mod legt seine eigene
|
||||||
|
# Tabelle an, bindet `core:floor_3_table` als Untertabelle ein und haengt die
|
||||||
|
# Etage um. Zwei gewoehnliche Ueberschreibungen, kein zweiter Mechanismus.
|
||||||
|
|
||||||
|
# --- Affixe ---------------------------------------------------------------
|
||||||
|
# Abstufungen sind EIGENE Eintraege mit kleinerem Gewicht, keine Zahl am
|
||||||
|
# Exemplar. Deshalb erhoeht ein zusaetzlicher Zug auch die Chance auf die
|
||||||
|
# staerkere Stufe.
|
||||||
|
|
||||||
|
[[loot_tables]]
|
||||||
|
id = "weapon_affixes"
|
||||||
|
rolls = "0-1"
|
||||||
|
entries = [
|
||||||
|
{ affix = "core:sharp", weight = 6 },
|
||||||
|
{ affix = "core:flaming", weight = 3 },
|
||||||
|
{ affix = "core:greater_flaming", weight = 1 },
|
||||||
|
{ nothing = true, weight = 14 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[loot_tables]]
|
||||||
|
id = "armor_affixes"
|
||||||
|
rolls = "0-1"
|
||||||
|
entries = [
|
||||||
|
{ affix = "core:sturdy", weight = 5 },
|
||||||
|
{ affix = "core:warding", weight = 2 },
|
||||||
|
{ nothing = true, weight = 16 },
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- Etagen ---------------------------------------------------------------
|
||||||
|
|
||||||
|
[[loot_tables]]
|
||||||
|
id = "floor_1_table"
|
||||||
|
rolls = "1-2"
|
||||||
|
entries = [
|
||||||
|
{ item = "core:health_potion", weight = 12, count = "1-2" },
|
||||||
|
{ item = "core:gold", weight = 10, count = "3-9" },
|
||||||
|
{ item = "core:iron_sword", weight = 2, affixes = "core:weapon_affixes" },
|
||||||
|
{ item = "core:leather_cap", weight = 2, affixes = "core:armor_affixes" },
|
||||||
|
{ nothing = true, weight = 8 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[loot_tables]]
|
||||||
|
id = "floor_3_table"
|
||||||
|
rolls = "1-3"
|
||||||
|
entries = [
|
||||||
|
{ item = "core:health_potion", weight = 10, count = "1-2" },
|
||||||
|
{ item = "core:gold", weight = 10, count = "6-18" },
|
||||||
|
{ item = "core:iron_sword", weight = 3, affixes = "core:weapon_affixes" },
|
||||||
|
{ item = "core:thorn_mail", weight = 1, affixes = "core:armor_affixes" },
|
||||||
|
{ table = "core:rare_table", weight = 2 },
|
||||||
|
{ nothing = true, weight = 6 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[loot_tables]]
|
||||||
|
id = "floor_5_table"
|
||||||
|
rolls = "2-3"
|
||||||
|
entries = [
|
||||||
|
{ item = "core:health_potion", weight = 8, count = "2-3" },
|
||||||
|
{ item = "core:gold", weight = 10, count = "15-40" },
|
||||||
|
{ item = "core:iron_sword", weight = 4, affixes = "core:weapon_affixes" },
|
||||||
|
{ item = "core:thorn_mail", weight = 3, affixes = "core:armor_affixes" },
|
||||||
|
{ table = "core:rare_table", weight = 4 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[loot_tables]]
|
||||||
|
id = "rare_table"
|
||||||
|
rolls = "1-1"
|
||||||
|
entries = [
|
||||||
|
{ item = "core:bone_blade", weight = 3, affixes = "core:weapon_affixes" },
|
||||||
|
{ item = "core:thorn_mail", weight = 4, affixes = "core:armor_affixes" },
|
||||||
|
]
|
||||||
|
|
||||||
|
# `guaranteed` nimmt an der Gewichtung nicht teil und kommt immer — mit
|
||||||
|
# Gewichten allein laesst sich "sicher" nicht ausdruecken.
|
||||||
|
[[loot_tables]]
|
||||||
|
id = "boss_table"
|
||||||
|
rolls = "1-2"
|
||||||
|
entries = [
|
||||||
|
{ item = "core:bone_blade", guaranteed = true, affixes = "core:weapon_affixes" },
|
||||||
|
{ item = "core:gold", weight = 10, count = "50-120" },
|
||||||
|
{ item = "core:health_potion", weight = 6, count = "2-4" },
|
||||||
|
]
|
||||||
@@ -15,8 +15,8 @@ use serde::de::DeserializeOwned;
|
|||||||
use toml::Spanned;
|
use toml::Spanned;
|
||||||
|
|
||||||
use super::registries::{
|
use super::registries::{
|
||||||
Ability, Category, CharacterOption, CreatureDef, FloorDef, Item, ModifierDef, Prefab,
|
Ability, Affix, Category, CharacterOption, CreatureDef, FloorDef, Item, LootTable, ModifierDef,
|
||||||
Registries, Slot,
|
Prefab, Registries, Slot,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Genau drei harte Fehler (Ticket 04). Alles Uebrige ist Warnung plus
|
/// Genau drei harte Fehler (Ticket 04). Alles Uebrige ist Warnung plus
|
||||||
@@ -257,6 +257,10 @@ struct ModFile {
|
|||||||
creatures: Vec<Spanned<toml::Value>>,
|
creatures: Vec<Spanned<toml::Value>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
modifiers: Vec<Spanned<toml::Value>>,
|
modifiers: Vec<Spanned<toml::Value>>,
|
||||||
|
#[serde(default)]
|
||||||
|
affixes: Vec<Spanned<toml::Value>>,
|
||||||
|
#[serde(default)]
|
||||||
|
loot_tables: Vec<Spanned<toml::Value>>,
|
||||||
/// **Keine** Tabelle mit `[[...]]`, sondern ein einzelner Block, der
|
/// **Keine** Tabelle mit `[[...]]`, sondern ein einzelner Block, der
|
||||||
/// feldweise verschmilzt — die ausdrueckliche Ausnahme zu Ticket 03.
|
/// feldweise verschmilzt — die ausdrueckliche Ausnahme zu Ticket 03.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -294,6 +298,8 @@ impl_entry!(FloorDef);
|
|||||||
impl_entry!(Ability);
|
impl_entry!(Ability);
|
||||||
impl_entry!(CreatureDef);
|
impl_entry!(CreatureDef);
|
||||||
impl_entry!(ModifierDef);
|
impl_entry!(ModifierDef);
|
||||||
|
impl_entry!(Affix);
|
||||||
|
impl_entry!(LootTable);
|
||||||
|
|
||||||
struct FileCtx<'a> {
|
struct FileCtx<'a> {
|
||||||
module: &'a str,
|
module: &'a str,
|
||||||
@@ -502,6 +508,8 @@ pub fn load_mods() -> Result<(Registries, Vec<String>), LoadError> {
|
|||||||
parse_entries(parsed.abilities, &ctx, &mut reg.abilities, &mut warnings);
|
parse_entries(parsed.abilities, &ctx, &mut reg.abilities, &mut warnings);
|
||||||
parse_entries(parsed.creatures, &ctx, &mut reg.creatures, &mut warnings);
|
parse_entries(parsed.creatures, &ctx, &mut reg.creatures, &mut warnings);
|
||||||
parse_entries(parsed.modifiers, &ctx, &mut reg.modifiers, &mut warnings);
|
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);
|
||||||
|
|
||||||
// `[balance]` verschmilzt **feldweise** (Ticket 14): Ein Mod, der
|
// `[balance]` verschmilzt **feldweise** (Ticket 14): Ein Mod, der
|
||||||
// einen Knopf dreht, schreibt eine Zeile — und ein neuer Knopf in
|
// einen Knopf dreht, schreibt eine Zeile — und ein neuer Knopf in
|
||||||
|
|||||||
@@ -5,4 +5,6 @@ pub mod loader;
|
|||||||
pub mod registries;
|
pub mod registries;
|
||||||
|
|
||||||
pub use loader::{load_mods, write_log};
|
pub use loader::{load_mods, write_log};
|
||||||
pub use registries::{Balance, CreatureDef, FloorDef, Prefab, Registries};
|
pub use registries::{
|
||||||
|
Balance, CreatureDef, FloorDef, LootEntry, Prefab, Registries, parse_range,
|
||||||
|
};
|
||||||
|
|||||||
@@ -229,6 +229,90 @@ impl Default for Balance {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Eine benannte Modifikatorliste, die an einem **einzelnen Exemplar** haengt —
|
||||||
|
/// „Flammendes Eisenschwert" (Ticket 07).
|
||||||
|
///
|
||||||
|
/// Abstufungen sind **eigene Eintraege** mit kleinerem Gewicht (`core:flaming`
|
||||||
|
/// neben `core:greater_flaming`), keine Zahl am Exemplar: Eine Staerke je
|
||||||
|
/// Exemplar haette Save-Format, Preisformel und Stapelregel auf einmal
|
||||||
|
/// gekostet (Ticket 16).
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Affix {
|
||||||
|
pub id: String,
|
||||||
|
/// Wird dem Gegenstandsnamen vorangestellt: „Flammendes Eisenschwert".
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default = "one_f32")]
|
||||||
|
pub price_factor: f32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub modifiers: Vec<Modifier>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub triggers: Vec<Trigger>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub overrides: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn one_f32() -> f32 {
|
||||||
|
1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Eintrag einer Beutetabelle. Genau eine der vier Formen (Ticket 16).
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct LootEntry {
|
||||||
|
#[serde(default = "one")]
|
||||||
|
pub weight: u32,
|
||||||
|
/// Wird **immer** ausgegeben und nimmt an der Gewichtung nicht teil — mit
|
||||||
|
/// Gewichten allein laesst sich „sicher" nicht ausdruecken, und Bosse
|
||||||
|
/// stehen in der Destination.
|
||||||
|
#[serde(default)]
|
||||||
|
pub guaranteed: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub item: Option<String>,
|
||||||
|
/// Tabelle-in-Tabelle: liefert Seltenheitsstufen, ohne dass „Seltenheit"
|
||||||
|
/// ein Begriff werden muss. Zugleich der **Mod-Einhaengepunkt** — ein Mod
|
||||||
|
/// bindet `core:floor_3_table` als Untertabelle ein.
|
||||||
|
#[serde(default)]
|
||||||
|
pub table: Option<String>,
|
||||||
|
/// Dieselbe Maschinerie zieht Gegenstaende *und* Affixe.
|
||||||
|
#[serde(default)]
|
||||||
|
pub affix: Option<String>,
|
||||||
|
/// Niete. Ein gewoehnlicher Eintrag, kein eigenes `drop_chance`-Feld — die
|
||||||
|
/// Engine kennt eine Mechanik statt zweier.
|
||||||
|
#[serde(default)]
|
||||||
|
pub nothing: bool,
|
||||||
|
|
||||||
|
/// Stapelmenge, als Bereich `"1-2"`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub count: Option<String>,
|
||||||
|
/// Affix-Tabelle fuer diesen Gegenstand.
|
||||||
|
#[serde(default)]
|
||||||
|
pub affixes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct LootTable {
|
||||||
|
pub id: String,
|
||||||
|
/// Wie viele Zuege, als Bereich `"1-3"`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub rolls: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub entries: Vec<LootEntry>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub overrides: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bereichsangabe `"1-3"` oder `"2"`. Ein Zweizeiler statt einer Grammatik.
|
||||||
|
pub fn parse_range(spec: Option<&String>) -> (u32, u32) {
|
||||||
|
let Some(s) = spec else { return (1, 1) };
|
||||||
|
match s.split_once('-') {
|
||||||
|
Some((a, b)) => (a.trim().parse().unwrap_or(1), b.trim().parse().unwrap_or(1)),
|
||||||
|
None => {
|
||||||
|
let n = s.trim().parse().unwrap_or(1);
|
||||||
|
(n, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Ein von Hand gezeichneter Raum als ASCII-Block (Ticket 10).
|
/// Ein von Hand gezeichneter Raum als ASCII-Block (Ticket 10).
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct Prefab {
|
pub struct Prefab {
|
||||||
@@ -283,9 +367,11 @@ pub struct FloorDef {
|
|||||||
/// als bei Prefabs, weil eine Etage ihre Gegner benennen koennen soll.
|
/// als bei Prefabs, weil eine Etage ihre Gegner benennen koennen soll.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub creatures: Vec<WeightedId>,
|
pub creatures: Vec<WeightedId>,
|
||||||
|
/// Beutetabelle fuer die `l`-Plaetze (Ticket 16).
|
||||||
|
#[serde(default)]
|
||||||
|
pub loot: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub overrides: Option<String>,
|
pub overrides: Option<String>,
|
||||||
// TODO(M4): `loot` — die Beutetabelle fuer die `l`-Plaetze (Ticket 16).
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
@@ -309,9 +395,11 @@ pub struct Registries {
|
|||||||
pub abilities: HashMap<String, Ability>,
|
pub abilities: HashMap<String, Ability>,
|
||||||
pub creatures: HashMap<String, CreatureDef>,
|
pub creatures: HashMap<String, CreatureDef>,
|
||||||
pub modifiers: HashMap<String, ModifierDef>,
|
pub modifiers: HashMap<String, ModifierDef>,
|
||||||
|
pub affixes: HashMap<String, Affix>,
|
||||||
|
pub loot_tables: HashMap<String, LootTable>,
|
||||||
/// Keine Registry — ein Block mit festem Schema, feldweise verschmolzen.
|
/// Keine Registry — ein Block mit festem Schema, feldweise verschmolzen.
|
||||||
pub balance: Balance,
|
pub balance: Balance,
|
||||||
// TODO(M4+): `affixes`, `quests`, `loot_tables`, `scripts`.
|
// TODO(M5+): `quests` (Ticket 08) und `scripts` (Ticket 13).
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Registries {
|
impl Registries {
|
||||||
|
|||||||
@@ -13,13 +13,20 @@ pub enum Direction {
|
|||||||
|
|
||||||
/// Ticket 11 hat zwoelf Commands festgelegt; M1 braucht zwei davon.
|
/// Ticket 11 hat zwoelf Commands festgelegt; M1 braucht zwei davon.
|
||||||
///
|
///
|
||||||
// TODO(M3+): `UseAbility`, `UseItem`, `Equip`, `Unequip`, `Drop`, `PickUp`,
|
// TODO(M5+): `Talk`, `Buy`, `Sell` — und `AssignPoints` aus Ticket 15.
|
||||||
// `Talk`, `Buy`, `Sell` — und `AssignPoints` aus 15.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
Move(Direction),
|
Move(Direction),
|
||||||
Wait,
|
Wait,
|
||||||
Descend,
|
Descend,
|
||||||
|
/// Hebt den Haufen auf, auf dem der Held steht. Ticket 16: Der Inhalt wird
|
||||||
|
/// **erst hier** gezogen — moeglich nur, weil alle Haufen dasselbe Symbol
|
||||||
|
/// zeigen, die Anzeige also nichts verraet.
|
||||||
|
PickUp,
|
||||||
|
UseItem(usize),
|
||||||
|
Equip(usize),
|
||||||
|
Unequip(String),
|
||||||
|
Drop(usize),
|
||||||
/// Der normale Angriff ist **keine eigene Variante**, sondern
|
/// Der normale Angriff ist **keine eigene Variante**, sondern
|
||||||
/// `UseAbility("core:attack")` — Faehigkeiten sind seit Ticket 02 eine
|
/// `UseAbility("core:attack")` — Faehigkeiten sind seit Ticket 02 eine
|
||||||
/// Registry, also gehoert er dorthin, und ein Mod kann ihn ersetzen.
|
/// Registry, also gehoert er dorthin, und ein Mod kann ihn ersetzen.
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ pub struct Room {
|
|||||||
/// Die Gegner dieses Raums. Wandern beim Betreten nach `Game.enemies` —
|
/// Die Gegner dieses Raums. Wandern beim Betreten nach `Game.enemies` —
|
||||||
/// **verschoben, nicht kopiert** (Ticket 10).
|
/// **verschoben, nicht kopiert** (Ticket 10).
|
||||||
pub enemies: Vec<super::creature::Creature>,
|
pub enemies: Vec<super::creature::Creature>,
|
||||||
|
/// Aus welcher Tabelle die Haufen dieses Raums ziehen — Prefab-`loot` oder,
|
||||||
|
/// wenn es fehlt, die Etagentabelle. **Eine** ID je Raum: Alle Haufen ziehen
|
||||||
|
/// ohnehin aus derselben, sie je Haufen zu wiederholen waere Redundanz
|
||||||
|
/// (Ticket 16).
|
||||||
|
pub loot_table: String,
|
||||||
/// Die `l`-Plaetze. Ticket 16: erst beim **Aufheben** aufgeloest, deshalb
|
/// Die `l`-Plaetze. Ticket 16: erst beim **Aufheben** aufgeloest, deshalb
|
||||||
/// stehen hier Positionen und keine Gegenstaende.
|
/// stehen hier Positionen und keine Gegenstaende.
|
||||||
pub loot: Vec<(u16, u16)>,
|
pub loot: Vec<(u16, u16)>,
|
||||||
|
|||||||
@@ -142,11 +142,22 @@ pub fn generate(def: &FloorDef, reg: &Registries, rng: &mut Rng) -> Floor {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
// Ein Prefab darf ueber sein optionales `loot`-Feld die Etagentabelle
|
||||||
|
// ersetzen — sonst ist ein Schatzkammer-Raum nur ein Raum mit mehr
|
||||||
|
// `l`-Zeichen (Ticket 16).
|
||||||
|
let loot_table = reg
|
||||||
|
.prefabs
|
||||||
|
.get(&p.prefab)
|
||||||
|
.and_then(|pf| pf.loot.clone())
|
||||||
|
.or_else(|| def.loot.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
rooms.push(Room {
|
rooms.push(Room {
|
||||||
prefab: p.prefab.clone(),
|
prefab: p.prefab.clone(),
|
||||||
rect: p.rect,
|
rect: p.rect,
|
||||||
enemy_spawns,
|
enemy_spawns,
|
||||||
enemies,
|
enemies,
|
||||||
|
loot_table,
|
||||||
loot,
|
loot,
|
||||||
cleared: false,
|
cleared: false,
|
||||||
});
|
});
|
||||||
|
|||||||
207
src/game/loot.rs
Normal file
207
src/game/loot.rs
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
//! Beutetabellen ziehen — Ticket 16.
|
||||||
|
//!
|
||||||
|
//! **Eine** Maschinerie fuer Gegenstaende *und* Affixe: dieselben Gewichte,
|
||||||
|
//! dieselbe Tabelle-in-Tabelle, dieselben Nieten.
|
||||||
|
//!
|
||||||
|
//! Gewichte statt Prozente ist keine Geschmacksfrage, sondern eine
|
||||||
|
//! Modding-Frage: Prozente muessen sich auf 100 summieren, und ein hinzugefuegter
|
||||||
|
//! Eintrag macht die Summe kaputt.
|
||||||
|
|
||||||
|
use crate::data::{Registries, parse_range};
|
||||||
|
|
||||||
|
use super::creature::ItemInstance;
|
||||||
|
use super::rng::Rng;
|
||||||
|
|
||||||
|
/// Zieht aus einer Tabelle. Rekursion ueber Tabelle-in-Tabelle ist mit einer
|
||||||
|
/// Tiefe gedeckelt — eine Tabelle, die sich selbst einbindet, wuerde sonst
|
||||||
|
/// endlos laufen, und das ist ein Datenfehler, kein Absturzgrund.
|
||||||
|
pub fn roll(table: &str, reg: &Registries, rng: &mut Rng) -> Vec<ItemInstance> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
draw(table, reg, rng, 0, &mut out);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(
|
||||||
|
table_id: &str,
|
||||||
|
reg: &Registries,
|
||||||
|
rng: &mut Rng,
|
||||||
|
depth: u8,
|
||||||
|
out: &mut Vec<ItemInstance>,
|
||||||
|
) {
|
||||||
|
if depth > 8 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(table) = reg.loot_tables.get(table_id) else { return };
|
||||||
|
|
||||||
|
// `guaranteed` nimmt an der Gewichtung nicht teil und kommt immer.
|
||||||
|
for entry in table.entries.iter().filter(|e| e.guaranteed) {
|
||||||
|
emit(entry, reg, rng, depth, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pool: Vec<&crate::data::LootEntry> =
|
||||||
|
table.entries.iter().filter(|e| !e.guaranteed).collect();
|
||||||
|
if pool.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let weights: Vec<u32> = pool.iter().map(|e| e.weight).collect();
|
||||||
|
|
||||||
|
let (lo, hi) = parse_range(table.rolls.as_ref());
|
||||||
|
let rolls = rng.range(lo, hi);
|
||||||
|
for _ in 0..rolls {
|
||||||
|
if let Some(i) = rng.weighted(&weights) {
|
||||||
|
emit(pool[i], reg, rng, depth, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit(
|
||||||
|
entry: &crate::data::LootEntry,
|
||||||
|
reg: &Registries,
|
||||||
|
rng: &mut Rng,
|
||||||
|
depth: u8,
|
||||||
|
out: &mut Vec<ItemInstance>,
|
||||||
|
) {
|
||||||
|
// Niete: ein gewoehnlicher Eintrag, kein eigenes `drop_chance`-Feld.
|
||||||
|
if entry.nothing {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(sub) = &entry.table {
|
||||||
|
draw(sub, reg, rng, depth + 1, out);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Affix-Eintrag ausserhalb einer Affix-Tabelle ergibt keinen
|
||||||
|
// Gegenstand — er wird von `affixes_for` gelesen, nicht hier.
|
||||||
|
if entry.affix.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(item) = &entry.item else { return };
|
||||||
|
if !reg.items.contains_key(item) {
|
||||||
|
return; // beim Laden gemeldet
|
||||||
|
}
|
||||||
|
|
||||||
|
let affixes = match &entry.affixes {
|
||||||
|
Some(table) => affixes_for(table, reg, rng),
|
||||||
|
None => Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
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() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zieht Affixe aus derselben Tabellenmaschinerie. Abstufungen sind eigene
|
||||||
|
/// Eintraege mit kleinerem Gewicht — deshalb erhoeht ein zusaetzlicher Zug
|
||||||
|
/// (Glueck) auch die Chance auf die staerkere Stufe.
|
||||||
|
fn affixes_for(table_id: &str, reg: &Registries, rng: &mut Rng) -> Vec<String> {
|
||||||
|
let Some(table) = reg.loot_tables.get(table_id) else { return Vec::new() };
|
||||||
|
let weights: Vec<u32> = table.entries.iter().map(|e| e.weight).collect();
|
||||||
|
|
||||||
|
let (lo, hi) = parse_range(table.rolls.as_ref());
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for _ in 0..rng.range(lo, hi) {
|
||||||
|
let Some(i) = rng.weighted(&weights) else { continue };
|
||||||
|
let entry = &table.entries[i];
|
||||||
|
if entry.nothing {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(affix) = &entry.affix
|
||||||
|
&& reg.affixes.contains_key(affix)
|
||||||
|
&& !out.contains(affix)
|
||||||
|
{
|
||||||
|
out.push(affix.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::data::load_mods;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn niete_liefert_nichts_und_stuerzt_nicht_ab() {
|
||||||
|
let (reg, _) = load_mods().expect("core");
|
||||||
|
let mut rng = Rng::new(1);
|
||||||
|
// Eine Tabelle, die es nicht gibt, ist kein Absturzgrund.
|
||||||
|
assert!(roll("gibtsnicht", ®, &mut rng).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn garantierter_eintrag_kommt_immer() {
|
||||||
|
let (reg, _) = load_mods().expect("core");
|
||||||
|
for seed in 1..30u64 {
|
||||||
|
let mut rng = Rng::new(seed);
|
||||||
|
let beute = roll("core:boss_table", ®, &mut rng);
|
||||||
|
assert!(
|
||||||
|
beute.iter().any(|i| i.def == "core:bone_blade"),
|
||||||
|
"seed {seed}: das Einzelstueck muss fallen"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn affixe_kommen_aus_derselben_maschinerie() {
|
||||||
|
let (reg, _) = load_mods().expect("core");
|
||||||
|
let mut mit_affix = 0;
|
||||||
|
for seed in 1..80u64 {
|
||||||
|
let mut rng = Rng::new(seed);
|
||||||
|
for inst in roll("core:floor_5_table", ®, &mut rng) {
|
||||||
|
if !inst.affixes.is_empty() {
|
||||||
|
mit_affix += 1;
|
||||||
|
// Jeder gezogene Affix muss existieren.
|
||||||
|
assert!(inst.affixes.iter().all(|a| reg.affixes.contains_key(a)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(mit_affix > 0, "auf Etage 5 muessen Affixe vorkommen");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derselbe_seed_liefert_dieselbe_beute() {
|
||||||
|
let (reg, _) = load_mods().expect("core");
|
||||||
|
let a = roll("core:floor_3_table", ®, &mut Rng::new(77));
|
||||||
|
let b = roll("core:floor_3_table", ®, &mut Rng::new(77));
|
||||||
|
let ids = |v: &[ItemInstance]| {
|
||||||
|
v.iter().map(|i| (i.def.clone(), i.affixes.clone())).collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
assert_eq!(ids(&a), ids(&b), "sonst waere der Seed im Save wertlos");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zwei Tabellen, die sich gegenseitig einbinden, sind ein Datenfehler —
|
||||||
|
/// aber kein Absturzgrund. Der Tiefendeckel ist die Stelle, die das traegt,
|
||||||
|
/// also braucht sie einen Test. Gebaut von Hand, damit `core` keine kaputten
|
||||||
|
/// Daten ausliefert.
|
||||||
|
#[test]
|
||||||
|
fn wechselseitiger_bezug_haengt_nicht() {
|
||||||
|
use crate::data::registries::{LootEntry, LootTable};
|
||||||
|
use crate::data::Registries;
|
||||||
|
|
||||||
|
let tabelle = |id: &str, other: &str| LootTable {
|
||||||
|
id: id.to_string(),
|
||||||
|
rolls: Some(String::from("1-1")),
|
||||||
|
entries: vec![LootEntry {
|
||||||
|
weight: 1,
|
||||||
|
guaranteed: false,
|
||||||
|
item: None,
|
||||||
|
table: Some(other.to_string()),
|
||||||
|
affix: None,
|
||||||
|
nothing: false,
|
||||||
|
count: None,
|
||||||
|
affixes: None,
|
||||||
|
}],
|
||||||
|
overrides: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut reg = Registries::default();
|
||||||
|
reg.loot_tables.insert(String::from("a"), tabelle("a", "b"));
|
||||||
|
reg.loot_tables.insert(String::from("b"), tabelle("b", "a"));
|
||||||
|
|
||||||
|
// Terminiert, statt den Stack zu sprengen.
|
||||||
|
assert!(roll("a", ®, &mut Rng::new(5)).is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
250
src/game/mod.rs
250
src/game/mod.rs
@@ -9,6 +9,7 @@ pub mod creature;
|
|||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod floor;
|
pub mod floor;
|
||||||
pub mod generate;
|
pub mod generate;
|
||||||
|
pub mod loot;
|
||||||
pub mod rng;
|
pub mod rng;
|
||||||
|
|
||||||
pub use action::{Action, ActiveModifier, DamageType, StateField, Target};
|
pub use action::{Action, ActiveModifier, DamageType, StateField, Target};
|
||||||
@@ -111,12 +112,17 @@ impl Game {
|
|||||||
.filter_map(|id| reg.options.get(id))
|
.filter_map(|id| reg.options.get(id))
|
||||||
.flat_map(|o| o.modifiers.iter());
|
.flat_map(|o| o.modifiers.iter());
|
||||||
|
|
||||||
|
// Gegenstands- **und** Affix-Modifikatoren des Exemplars. Ein Affix ist
|
||||||
|
// strukturell dasselbe wie eine Charakteroption — eine benannte
|
||||||
|
// Modifikatorliste (Ticket 07).
|
||||||
let from_equipment = c.equipment.values().flat_map(|inst| {
|
let from_equipment = c.equipment.values().flat_map(|inst| {
|
||||||
reg.items
|
let item = reg.items.get(&inst.def).map(|i| i.modifiers.as_slice()).unwrap_or(&[]);
|
||||||
.get(&inst.def)
|
let affixes = inst
|
||||||
.map(|i| i.modifiers.as_slice())
|
.affixes
|
||||||
.unwrap_or(&[])
|
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter_map(|a| reg.affixes.get(a))
|
||||||
|
.flat_map(|a| a.modifiers.iter());
|
||||||
|
item.iter().chain(affixes)
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut value = attributes::stack(c.base.get(attr), from_options.chain(from_equipment), attr);
|
let mut value = attributes::stack(c.base.get(attr), from_options.chain(from_equipment), attr);
|
||||||
@@ -131,8 +137,8 @@ impl Game {
|
|||||||
.fold((0.0, 0.0), |(f, p), m| (f + m.flat, p + m.percent));
|
.fold((0.0, 0.0), |(f, p), m| (f + m.flat, p + m.percent));
|
||||||
value = (value + flat) * (1.0 + percent / 100.0);
|
value = (value + flat) * (1.0 + percent / 100.0);
|
||||||
|
|
||||||
// TODO(M4): Affix-Modifikatoren des Exemplars und `_against` fuer
|
// TODO(M5): `_against` fuer bedingte Modifikatoren („+5 gegen Untote",
|
||||||
// bedingte Modifikatoren („+5 gegen Untote", Ticket 08).
|
// Ticket 08) — der Parameter steht bereits in der Signatur.
|
||||||
value
|
value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +157,13 @@ impl Game {
|
|||||||
.filter_map(|id| reg.options.get(id))
|
.filter_map(|id| reg.options.get(id))
|
||||||
.flat_map(|o| o.modifiers.iter());
|
.flat_map(|o| o.modifiers.iter());
|
||||||
let from_equipment = c.equipment.values().flat_map(|inst| {
|
let from_equipment = c.equipment.values().flat_map(|inst| {
|
||||||
reg.items.get(&inst.def).map(|i| i.modifiers.as_slice()).unwrap_or(&[]).iter()
|
let item = reg.items.get(&inst.def).map(|i| i.modifiers.as_slice()).unwrap_or(&[]);
|
||||||
|
let affixes = inst
|
||||||
|
.affixes
|
||||||
|
.iter()
|
||||||
|
.filter_map(|a| reg.affixes.get(a))
|
||||||
|
.flat_map(|a| a.modifiers.iter());
|
||||||
|
item.iter().chain(affixes)
|
||||||
});
|
});
|
||||||
|
|
||||||
let flat: f32 = from_options
|
let flat: f32 = from_options
|
||||||
@@ -243,8 +255,11 @@ impl Game {
|
|||||||
if let Some(item) = reg.items.get(&inst.def) {
|
if let Some(item) = reg.items.get(&inst.def) {
|
||||||
out.extend(item.triggers.iter());
|
out.extend(item.triggers.iter());
|
||||||
}
|
}
|
||||||
// TODO(M4): Affix-Trigger des Exemplars, sobald die Affix-Registry
|
for affix in &inst.affixes {
|
||||||
// steht — dieselben zwei Zeilen.
|
if let Some(def) = reg.affixes.get(affix) {
|
||||||
|
out.extend(def.triggers.iter());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
out.retain(|t| t.on == kind);
|
out.retain(|t| t.on == kind);
|
||||||
out
|
out
|
||||||
@@ -517,6 +532,30 @@ impl Game {
|
|||||||
Command::UseAbility { ability, .. } => {
|
Command::UseAbility { ability, .. } => {
|
||||||
self.ability_block(CreatureRef::Player, ability, reg)
|
self.ability_block(CreatureRef::Player, ability, reg)
|
||||||
}
|
}
|
||||||
|
Command::PickUp if in_combat => Some("Nicht im Kampf"),
|
||||||
|
Command::PickUp => {
|
||||||
|
let (x, y) = self.player_pos;
|
||||||
|
let hier = self
|
||||||
|
.floor
|
||||||
|
.player_room
|
||||||
|
.is_some_and(|i| self.floor.rooms[i].loot.contains(&(x, y)));
|
||||||
|
(!hier).then_some("Hier liegt nichts")
|
||||||
|
}
|
||||||
|
Command::UseItem(i) | Command::Drop(i) => {
|
||||||
|
(*i >= self.player.inventory.len()).then_some("Kein solcher Gegenstand")
|
||||||
|
}
|
||||||
|
Command::Equip(i) => {
|
||||||
|
let Some(inst) = self.player.inventory.get(*i) else {
|
||||||
|
return Some("Kein solcher Gegenstand");
|
||||||
|
};
|
||||||
|
match reg.items.get(&inst.def).and_then(|d| d.slot.as_ref()) {
|
||||||
|
Some(_) => None,
|
||||||
|
None => Some("Das kann man nicht anlegen"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command::Unequip(slot) => {
|
||||||
|
(!self.player.equipment.contains_key(slot)).then_some("Dort ist nichts")
|
||||||
|
}
|
||||||
Command::Wait => None,
|
Command::Wait => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -530,8 +569,13 @@ impl Game {
|
|||||||
pub fn abilities_of(&self, who: CreatureRef, reg: &Registries) -> Vec<String> {
|
pub fn abilities_of(&self, who: CreatureRef, reg: &Registries) -> Vec<String> {
|
||||||
let mut out = vec![String::from("core:attack"), String::from("core:flee")];
|
let mut out = vec![String::from("core:attack"), String::from("core:flee")];
|
||||||
out.extend(self.creature(who).abilities.iter().cloned());
|
out.extend(self.creature(who).abilities.iter().cloned());
|
||||||
// TODO(M4): Faehigkeiten aus Charakteroptionen und Ausruestung — beide
|
for inst in self.creature(who).equipment.values() {
|
||||||
// Registries brauchen dafuer erst ein `abilities`-Feld.
|
if let Some(item) = reg.items.get(&inst.def) {
|
||||||
|
out.extend(item.abilities.iter().cloned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TODO(M5): Faehigkeiten aus Charakteroptionen — die Registry braucht
|
||||||
|
// dafuer erst ein `abilities`-Feld (Ticket 02).
|
||||||
out.retain(|id| reg.abilities.contains_key(id));
|
out.retain(|id| reg.abilities.contains_key(id));
|
||||||
out.dedup();
|
out.dedup();
|
||||||
out
|
out
|
||||||
@@ -598,9 +642,114 @@ impl Game {
|
|||||||
Command::UseAbility { ability, target } => {
|
Command::UseAbility { ability, target } => {
|
||||||
self.use_ability(CreatureRef::Player, ability, *target, reg)
|
self.use_ability(CreatureRef::Player, ability, *target, reg)
|
||||||
}
|
}
|
||||||
|
Command::PickUp => self.pick_up(reg),
|
||||||
|
Command::UseItem(i) => self.use_item(*i, reg),
|
||||||
|
Command::Equip(i) => self.equip(*i, reg),
|
||||||
|
Command::Unequip(slot) => self.unequip(&slot.clone()),
|
||||||
|
Command::Drop(i) => {
|
||||||
|
let inst = self.player.inventory.remove(*i);
|
||||||
|
let name = self.item_label(&inst, reg);
|
||||||
|
// Fallen gelassenes wird nicht zu einem neuen Haufen: Ein Haufen
|
||||||
|
// traegt eine Tabellen-ID, keinen Gegenstand (Ticket 16).
|
||||||
|
// TODO(M5): Wenn Wegwerfen zurueckholbar sein soll, braucht ein
|
||||||
|
// Raum eine zweite Liste fuer konkrete Instanzen.
|
||||||
|
self.log(format!("{name} weggeworfen"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ticket 16: Der Inhalt eines Haufens wird **erst beim Aufheben** gezogen.
|
||||||
|
/// Ein Gluecksring wirkt damit auch auf Beute, die auf dieser Etage schon
|
||||||
|
/// liegt — bei Ziehung zur Erzeugungszeit waere der Wurf laengst gefallen.
|
||||||
|
fn pick_up(&mut self, reg: &Registries) {
|
||||||
|
let (x, y) = self.player_pos;
|
||||||
|
let Some(room) = self.floor.player_room else { return };
|
||||||
|
let table = self.floor.rooms[room].loot_table.clone();
|
||||||
|
self.floor.rooms[room].loot.retain(|p| *p != (x, y));
|
||||||
|
|
||||||
|
let beute = loot::roll(&table, reg, &mut self.rng);
|
||||||
|
if beute.is_empty() {
|
||||||
|
// Faellt der Wurf auf eine Niete, verschwindet der Haufen mit einer
|
||||||
|
// Meldung. Kein Neuwuerfeln — das haengt sich bei einer reinen
|
||||||
|
// Nieten-Tabelle auf (Ticket 16).
|
||||||
|
self.log(String::from("Nichts Brauchbares."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let namen: Vec<String> = beute.iter().map(|i| self.item_label(i, reg)).collect();
|
||||||
|
self.player.inventory.extend(beute);
|
||||||
|
self.log(format!("Aufgehoben: {}", namen.join(", ")));
|
||||||
|
self.events.push_back(Event::new(EventKind::ItemPickedUp, CreatureRef::Player));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
|
||||||
|
let name = self.item_label(&inst, reg);
|
||||||
|
let target = if self.enemies.is_empty() {
|
||||||
|
CreatureRef::Player
|
||||||
|
} else {
|
||||||
|
CreatureRef::Enemy(0)
|
||||||
|
};
|
||||||
|
for action in &def.actions {
|
||||||
|
self.apply(action, CreatureRef::Player, target, reg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if def.consumed_on_use {
|
||||||
|
self.player.inventory.remove(index);
|
||||||
|
}
|
||||||
|
self.log(format!("{name} benutzt"));
|
||||||
|
self.events.push_back(Event::new(EventKind::ItemUsed, CreatureRef::Player));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn equip(&mut self, index: usize, reg: &Registries) {
|
||||||
|
let inst = self.player.inventory[index].clone();
|
||||||
|
let Some(slot) = reg.items.get(&inst.def).and_then(|d| d.slot.clone()) else { return };
|
||||||
|
|
||||||
|
// Was im Slot lag, wandert zurueck ins Inventar — nicht ins Nichts.
|
||||||
|
if let Some(alt) = self.player.equipment.remove(&slot) {
|
||||||
|
self.player.inventory.push(alt);
|
||||||
|
}
|
||||||
|
self.player.inventory.remove(index);
|
||||||
|
let name = self.item_label(&inst, reg);
|
||||||
|
self.player.equipment.insert(slot, inst);
|
||||||
|
self.log(format!("{name} angelegt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unequip(&mut self, slot: &str) {
|
||||||
|
if let Some(inst) = self.player.equipment.remove(slot) {
|
||||||
|
self.player.inventory.push(inst);
|
||||||
|
self.log(String::from("Abgelegt"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Anzeigename eines Exemplars: Affixe werden vorangestellt —
|
||||||
|
/// „Flammendes Eisenschwert" (Ticket 07).
|
||||||
|
pub fn item_label(&self, inst: &ItemInstance, reg: &Registries) -> String {
|
||||||
|
let base = reg.items.get(&inst.def).map(|d| d.name.as_str()).unwrap_or(&inst.def);
|
||||||
|
let mut teile: Vec<&str> = inst
|
||||||
|
.affixes
|
||||||
|
.iter()
|
||||||
|
.filter_map(|a| reg.affixes.get(a).map(|d| d.name.as_str()))
|
||||||
|
.collect();
|
||||||
|
teile.push(base);
|
||||||
|
teile.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preis eines Exemplars: `base_price × Π price_factor` der Affixe
|
||||||
|
/// (Ticket 07) — eine Multiplikation, keine Rechnung mit Sonderfaellen.
|
||||||
|
pub fn item_price(&self, inst: &ItemInstance, reg: &Registries) -> u32 {
|
||||||
|
let base = reg.items.get(&inst.def).map_or(0, |d| d.base_price) as f32;
|
||||||
|
let faktor: f32 = inst
|
||||||
|
.affixes
|
||||||
|
.iter()
|
||||||
|
.filter_map(|a| reg.affixes.get(a))
|
||||||
|
.map(|a| a.price_factor)
|
||||||
|
.product();
|
||||||
|
(base * faktor).round() as u32
|
||||||
|
}
|
||||||
|
|
||||||
fn use_ability(
|
fn use_ability(
|
||||||
&mut self,
|
&mut self,
|
||||||
who: CreatureRef,
|
who: CreatureRef,
|
||||||
@@ -901,6 +1050,85 @@ mod tests {
|
|||||||
assert_eq!(game.over, Some(RunOutcome::Died), "irgendwann trifft ein Gegner");
|
assert_eq!(game.over, Some(RunOutcome::Died), "irgendwann trifft ein Gegner");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ticket 16: Der Inhalt eines Haufens wird **erst beim Aufheben** gezogen.
|
||||||
|
#[test]
|
||||||
|
fn aufheben_zieht_aus_der_tabelle() {
|
||||||
|
let (mut game, reg) = zwergenkrieger();
|
||||||
|
|
||||||
|
// Einen Raum mit Beute finden und sich draufstellen.
|
||||||
|
let i = (0..game.floor.rooms.len())
|
||||||
|
.find(|i| !game.floor.rooms[*i].loot.is_empty())
|
||||||
|
.expect("mindestens ein Raum hat Beuteplaetze");
|
||||||
|
game.floor.rooms[i].enemies.clear(); // kein Kampf im Weg
|
||||||
|
let pos = game.floor.rooms[i].loot[0];
|
||||||
|
game.player_pos = pos;
|
||||||
|
game.floor.player_room = Some(i);
|
||||||
|
|
||||||
|
let vorher = game.player.inventory.len();
|
||||||
|
assert!(matches!(game.execute(Command::PickUp, ®), Outcome::TurnPassed));
|
||||||
|
|
||||||
|
// Der Haufen ist weg — ob etwas drin war, entscheidet die Tabelle
|
||||||
|
// (Nieten sind gewoehnliche Eintraege).
|
||||||
|
assert!(!game.floor.rooms[i].loot.contains(&pos));
|
||||||
|
assert!(game.player.inventory.len() >= vorher);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn aufheben_wo_nichts_liegt_kostet_keinen_zug() {
|
||||||
|
let (mut game, reg) = zwergenkrieger();
|
||||||
|
game.player_pos = game.floor.entrance();
|
||||||
|
game.floor.player_room = game.floor.room_at(game.player_pos.0, game.player_pos.1);
|
||||||
|
|
||||||
|
assert!(matches!(game.execute(Command::PickUp, ®), Outcome::Rejected(_)));
|
||||||
|
assert_eq!(game.turn, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anlegen tauscht: Was im Slot lag, wandert **zurueck ins Inventar**, nicht
|
||||||
|
/// ins Nichts.
|
||||||
|
#[test]
|
||||||
|
fn anlegen_tauscht_statt_zu_verschlucken() {
|
||||||
|
let (mut game, reg) = zwergenkrieger();
|
||||||
|
game.player.inventory.push(ItemInstance::new("core:bone_blade"));
|
||||||
|
let index = game.player.inventory.len() - 1;
|
||||||
|
|
||||||
|
let alt = game.player.equipment.get("core:hand").map(|i| i.def.clone());
|
||||||
|
game.execute(Command::Equip(index), ®);
|
||||||
|
|
||||||
|
assert_eq!(game.player.equipment["core:hand"].def, "core:bone_blade");
|
||||||
|
if let Some(alt) = alt {
|
||||||
|
assert!(
|
||||||
|
game.player.inventory.iter().any(|i| i.def == alt),
|
||||||
|
"das alte Schwert muss zurueck ins Inventar"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ticket 07: Affixe sind Registry-Eintraege, und ihre Modifikatoren
|
||||||
|
/// gehoeren in denselben Attributpfad wie die des Gegenstands.
|
||||||
|
#[test]
|
||||||
|
fn affixe_wirken_auf_attribute_und_preis() {
|
||||||
|
let (mut game, reg) = zwergenkrieger();
|
||||||
|
let p = CreatureRef::Player;
|
||||||
|
|
||||||
|
let ohne = game.attribute(p, Attribute::Damage, None, ®);
|
||||||
|
let ohne_preis =
|
||||||
|
game.item_price(&ItemInstance::new("core:iron_sword"), ®);
|
||||||
|
|
||||||
|
let mut flammend = ItemInstance::new("core:iron_sword");
|
||||||
|
flammend.affixes.push(String::from("core:flaming"));
|
||||||
|
let mit_preis = game.item_price(&flammend, ®);
|
||||||
|
game.player.equipment.insert(String::from("core:hand"), flammend.clone());
|
||||||
|
|
||||||
|
let mit = game.attribute(p, Attribute::Damage, None, ®);
|
||||||
|
assert!(mit > ohne, "der Affix muss Schaden bringen: {mit} > {ohne}");
|
||||||
|
|
||||||
|
// Preis = base_price × Π price_factor (Ticket 07).
|
||||||
|
assert!(mit_preis > ohne_preis, "{mit_preis} > {ohne_preis}");
|
||||||
|
|
||||||
|
// Und der Anzeigename stellt den Affix voran.
|
||||||
|
assert_eq!(game.item_label(&flammend, ®), "Flammendes Eisenschwert");
|
||||||
|
}
|
||||||
|
|
||||||
/// Die Trefferformel aus Ticket 14 war beim ersten Anlauf **toter Code** —
|
/// Die Trefferformel aus Ticket 14 war beim ersten Anlauf **toter Code** —
|
||||||
/// jeder Angriff traf. Nur der Compiler hat es gemeldet („`hit_chance` is
|
/// jeder Angriff traf. Nur der Compiler hat es gemeldet („`hit_chance` is
|
||||||
/// never used"). Dieser Test haelt sie am Leben.
|
/// never used"). Dieser Test haelt sie am Leben.
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ impl Rng {
|
|||||||
if n == 0 { 0 } else { (self.next_u64() % n as u64) as usize }
|
if n == 0 { 0 } else { (self.next_u64() % n as u64) as usize }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Einschliesslich beider Grenzen.
|
||||||
|
pub fn range(&mut self, lo: u32, hi: u32) -> u32 {
|
||||||
|
if hi <= lo {
|
||||||
|
return lo;
|
||||||
|
}
|
||||||
|
lo + self.below((hi - lo + 1) as usize) as u32
|
||||||
|
}
|
||||||
|
|
||||||
pub fn chance(&mut self, p: f32) -> bool {
|
pub fn chance(&mut self, p: f32) -> bool {
|
||||||
(self.next_u64() % 10_000) < (p.clamp(0.0, 1.0) * 10_000.0) as u64
|
(self.next_u64() % 10_000) < (p.clamp(0.0, 1.0) * 10_000.0) as u64
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ fn draw(frame: &mut Frame, app: &App) {
|
|||||||
Screen::MainMenu => draw_main_menu(frame, area, app),
|
Screen::MainMenu => draw_main_menu(frame, area, app),
|
||||||
Screen::Explore => draw_explore(frame, area, app),
|
Screen::Explore => draw_explore(frame, area, app),
|
||||||
Screen::Combat { pick } => draw_combat(frame, area, app, *pick),
|
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::CharacterSheet => draw_sheet(frame, area, app),
|
||||||
Screen::GameOver => draw_game_over(frame, area, app),
|
Screen::GameOver => draw_game_over(frame, area, app),
|
||||||
}
|
}
|
||||||
@@ -159,7 +160,7 @@ fn draw_explore(frame: &mut Frame, area: Rect, app: &App) {
|
|||||||
};
|
};
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(format!(
|
Paragraph::new(format!(
|
||||||
"Zug {} │ {} │ hjkl/Pfeile .warten >absteigen c Bogen │ {}",
|
"Zug {} │ {} │ .warten g aufheben >absteigen i Inventar c Bogen │ {}",
|
||||||
app.game.turn, app.game.player.name, note
|
app.game.turn, app.game.player.name, note
|
||||||
))
|
))
|
||||||
.right_aligned()
|
.right_aligned()
|
||||||
@@ -168,6 +169,63 @@ fn draw_explore(frame: &mut Frame, area: Rect, app: &App) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Links das Inventar mit Cursor, rechts die Ausrüstung als **Schleife über die
|
||||||
|
/// Slot-Registry** — Modder können Slots ergänzen, ohne dass hier etwas
|
||||||
|
/// geändert wird (Ticket 02).
|
||||||
|
fn draw_inventory(frame: &mut Frame, area: Rect, app: &App, pick: usize) {
|
||||||
|
let block = Block::bordered().title(" Inventar ");
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
let [links, rechts] =
|
||||||
|
Layout::horizontal([Constraint::Percentage(60), Constraint::Min(0)]).areas(inner);
|
||||||
|
|
||||||
|
let mut zeilen = Vec::new();
|
||||||
|
for (i, inst) in app.game.player.inventory.iter().enumerate() {
|
||||||
|
let def = app.registries.items.get(&inst.def);
|
||||||
|
let name = app.game.item_label(inst, &app.registries);
|
||||||
|
let preis = app.game.item_price(inst, &app.registries);
|
||||||
|
let slot = def.and_then(|d| d.slot.as_deref()).unwrap_or("—");
|
||||||
|
|
||||||
|
let zeiger = if i == pick { "›" } else { " " };
|
||||||
|
let farbe = if i == pick { Color::Yellow } else { Color::Reset };
|
||||||
|
zeilen.push(
|
||||||
|
Line::from(format!("{zeiger} {name:<30} {slot:<12} {preis:>5} G"))
|
||||||
|
.style(Style::new().fg(farbe)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if zeilen.is_empty() {
|
||||||
|
zeilen.push(Line::from("leer").style(Style::new().fg(Color::DarkGray)));
|
||||||
|
}
|
||||||
|
zeilen.push(Line::from(""));
|
||||||
|
zeilen.push(
|
||||||
|
Line::from("↑↓ wählen · u benutzen · e anlegen · d wegwerfen · 1-9 ablegen · Esc")
|
||||||
|
.style(Style::new().fg(Color::DarkGray)),
|
||||||
|
);
|
||||||
|
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(zeilen).block(Block::bordered().title(" Getragen ")),
|
||||||
|
links,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut aus = Vec::new();
|
||||||
|
for (i, slot) in app.registries.slots_ordered().iter().enumerate() {
|
||||||
|
let getragen = app.game.player.equipment.get(&slot.id);
|
||||||
|
let text = match getragen {
|
||||||
|
Some(inst) => app.game.item_label(inst, &app.registries),
|
||||||
|
None => String::from("—"),
|
||||||
|
};
|
||||||
|
let farbe = if getragen.is_some() { Color::Reset } else { Color::DarkGray };
|
||||||
|
aus.push(
|
||||||
|
Line::from(format!("{} {:<10} {}", i + 1, slot.name, text))
|
||||||
|
.style(Style::new().fg(farbe)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(aus).block(Block::bordered().title(" Ausrüstung ")),
|
||||||
|
rechts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Menue-Kampf (Charter: Dungeon Master / Etrian Odyssey). Links die Gegner mit
|
/// Menue-Kampf (Charter: Dungeon Master / Etrian Odyssey). Links die Gegner mit
|
||||||
/// Ziffern, rechts die Faehigkeiten mit Kosten und **Sperrgrund** — der kommt
|
/// Ziffern, rechts die Faehigkeiten mit Kosten und **Sperrgrund** — der kommt
|
||||||
/// aus dem Spielkern, nicht aus der UI (Ticket 14).
|
/// aus dem Spielkern, nicht aus der UI (Ticket 14).
|
||||||
|
|||||||
@@ -36,6 +36,23 @@ impl Widget for GridWidget<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Beutehaufen: **ein** generisches Symbol fuer alles. Nur deshalb darf
|
||||||
|
// der Inhalt erst beim Aufheben gezogen werden — die Anzeige verraet
|
||||||
|
// nichts, was noch nicht gewuerfelt ist (Ticket 16).
|
||||||
|
for room in &self.floor.rooms {
|
||||||
|
for (lx, ly) in &room.loot {
|
||||||
|
if *lx < ox || *ly < oy {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (px, py) = (area.x + lx - ox, area.y + ly - oy);
|
||||||
|
if px < area.right() && py < area.bottom() {
|
||||||
|
buf[(px, py)]
|
||||||
|
.set_symbol("*")
|
||||||
|
.set_style(Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let (sx, sy) = (area.x + self.player.0 - ox, area.y + self.player.1 - oy);
|
let (sx, sy) = (area.x + self.player.0 - ox, area.y + self.player.1 - oy);
|
||||||
if sx < area.right() && sy < area.bottom() {
|
if sx < area.right() && sy < area.bottom() {
|
||||||
buf[(sx, sy)]
|
buf[(sx, sy)]
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ pub enum Screen {
|
|||||||
/// Cursor in der Faehigkeitsliste.
|
/// Cursor in der Faehigkeitsliste.
|
||||||
pick: usize,
|
pick: usize,
|
||||||
},
|
},
|
||||||
|
/// Zwei Bereiche: Inventar mit Cursor, Ausrüstung als Registry-Schleife.
|
||||||
|
Inventory {
|
||||||
|
pick: usize,
|
||||||
|
},
|
||||||
CharacterSheet,
|
CharacterSheet,
|
||||||
/// Vollbild, kein Overlay. Bewusst nicht die lazy Variante mit einer
|
/// Vollbild, kein Overlay. Bewusst nicht die lazy Variante mit einer
|
||||||
/// Meldungszeile im Hauptmenue: Ein Abstiegs-Roguelike lebt davon, dass der
|
/// Meldungszeile im Hauptmenue: Ein Abstiegs-Roguelike lebt davon, dass der
|
||||||
@@ -73,8 +77,10 @@ impl Screen {
|
|||||||
}
|
}
|
||||||
KeyCode::Char('.') => Response::Command(Command::Wait),
|
KeyCode::Char('.') => Response::Command(Command::Wait),
|
||||||
KeyCode::Char('>') => Response::Command(Command::Descend),
|
KeyCode::Char('>') => Response::Command(Command::Descend),
|
||||||
|
KeyCode::Char('g') => Response::Command(Command::PickUp),
|
||||||
// Screen-Navigation ist reine UI und fasst `Game` nie an.
|
// Screen-Navigation ist reine UI und fasst `Game` nie an.
|
||||||
KeyCode::Char('c') => Response::Push(Screen::CharacterSheet),
|
KeyCode::Char('c') => Response::Push(Screen::CharacterSheet),
|
||||||
|
KeyCode::Char('i') => Response::Push(Screen::Inventory { pick: 0 }),
|
||||||
KeyCode::Esc => Response::Pop,
|
KeyCode::Esc => Response::Pop,
|
||||||
_ => Response::None,
|
_ => Response::None,
|
||||||
},
|
},
|
||||||
@@ -118,6 +124,42 @@ impl Screen {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
Screen::CharacterSheet => match key {
|
||||||
KeyCode::Esc | KeyCode::Char('c') | KeyCode::Char('q') => Response::Pop,
|
KeyCode::Esc | KeyCode::Char('c') | KeyCode::Char('q') => Response::Pop,
|
||||||
_ => Response::None,
|
_ => Response::None,
|
||||||
|
|||||||
Reference in New Issue
Block a user