Merge branch 'feat/m8-charakterbau'
This commit is contained in:
@@ -116,3 +116,28 @@ dessen leerer Liste die KI aus Ticket 14 sonst stünde.
|
|||||||
`src/items/weapon.rs` und `src/items/armor.rs` werden gelöscht, `enum ItemType` entfällt,
|
`src/items/weapon.rs` und `src/items/armor.rs` werden gelöscht, `enum ItemType` entfällt,
|
||||||
`weapons.toml` und `armors.toml` verschmelzen zu einer gewöhnlichen Gegenstandsdatei, `items.rs`
|
`weapons.toml` und `armors.toml` verschmelzen zu einer gewöhnlichen Gegenstandsdatei, `items.rs`
|
||||||
schrumpft auf Laden plus Registry.
|
schrumpft auf Laden plus Registry.
|
||||||
|
|
||||||
|
### Befunde aus M8
|
||||||
|
|
||||||
|
**`abilities` und `on_create` standen seit M3 als TODO — und `abilities` war eine echte Lücke.**
|
||||||
|
Ticket 15 hat später argumentiert: *„Was Charakteroptionen dauerhaft trägt, sind Fähigkeiten — ein
|
||||||
|
Magier bleibt ein Magier, weil er zaubern kann."* Genau das Feld, auf dem dieses Argument ruht, gab es
|
||||||
|
nicht; eine Klasse konnte bis M8 ausschließlich flache Modifikatoren geben, also genau die Hälfte, die
|
||||||
|
laut demselben Ticket auf Stufe 30 zu Rauschen wird. Beide Felder sind jetzt da, `abilities_of` liest
|
||||||
|
sie, und Krieger wie Dieb tragen eine eigene Fähigkeit.
|
||||||
|
|
||||||
|
**`on_create` hat sich als das erwiesen, was es sein sollte: keine Mechanik.** Startausrüstung kommt
|
||||||
|
über `give_item`, Startgold über dasselbe — die Engine kennt den Begriff nicht. `starting_hero()` in
|
||||||
|
`main.rs`, das seit M1 Schwert, Kappe und Trank von Hand anlegte, ist auf `Creature::new(name)`
|
||||||
|
geschrumpft. Das war der eigentliche Test der Entscheidung: Der hartcodierte Held *war* die
|
||||||
|
Startausrüstungs-Sonderregel, nur an einer Stelle, an der niemand sie so genannt hat.
|
||||||
|
|
||||||
|
**„Registry-Schleife statt gemauertem Layout" trägt.** Der Charakterbau liest `categories_ordered()`
|
||||||
|
und `options_of()`; ein Mod, der „Talent" mit `pick = 2` ergänzt, bekommt eine dritte Spalte, ohne dass
|
||||||
|
eine Zeile Code davon weiß. Geprüft wird `pick` in `rejects`, nicht im Screen — der Screen zählt gar
|
||||||
|
nicht mit.
|
||||||
|
|
||||||
|
**Der Startpunkt braucht ein Flag, keine Herleitung.** Naheliegend wäre gewesen, „Held ist noch nicht
|
||||||
|
gebaut" aus `player.options.is_empty()` zu folgern. Das ist falsch: Ein Speicherstand, dem der
|
||||||
|
Rassen-Mod fehlt, hat nach der Verwerf-Regel aus Ticket 09 ebenfalls keine Optionen — und würde neu
|
||||||
|
gebaut statt fortgesetzt. `MainMenu { neu }` weiß es stattdessen von `main`, das den Unterschied kennt.
|
||||||
|
|||||||
@@ -198,6 +198,27 @@ Ticket 08 nennt `start_quest` die „siebte", Ticket 14 `flee` die „achte", M4
|
|||||||
gebaut sind neun Varianten. Jedes Ticket zählte von seinem eigenen Stand aus. Die Zahlen sind aus dem
|
gebaut sind neun Varianten. Jedes Ticket zählte von seinem eigenen Stand aus. Die Zahlen sind aus dem
|
||||||
Code raus, `CONTEXT.md` führt jetzt die Liste — die ist zählbar.
|
Code raus, `CONTEXT.md` führt jetzt die Liste — die ist zählbar.
|
||||||
|
|
||||||
|
**M8 — Charaktererschaffung** (`feat/m8-charakterbau`, 72 Tests)
|
||||||
|
|
||||||
|
Nicht Scripting: **Ticket 13 hat Stufe (c) ausdrücklich hinter 1.0 gelegt**, und die Destination oben
|
||||||
|
sagt dasselbe. Der Vorschlag „M8 = Scripts" kam aus den TODO-Kommentaren im Code, nicht aus dem Ticket.
|
||||||
|
Offen für 1.0 war stattdessen „Solo-Held mit **Rasse und Klasse**" — gebaut war davon nichts.
|
||||||
|
|
||||||
|
| Zusage | Aus | Befund |
|
||||||
|
|---|---|---|
|
||||||
|
| Charakterbau ist eine **Registry-Schleife**, kein gemauertes Layout | 02/11 | **bestätigt** — „Talent" mit `pick = 2` bräuchte keine Zeile Code |
|
||||||
|
| `pick = N` an der Kategorie | 02 | **bestätigt**, geprüft in `rejects`, nicht im Screen |
|
||||||
|
| `on_create` ist **keine neue Mechanik** | 02 | **bestätigt** — der hartcodierte `starting_hero()` *war* die Sonderregel und ist ersatzlos weg |
|
||||||
|
| Was eine Option dauerhaft trägt, sind Fähigkeiten | 15 | **das Feld gab es nicht** — siehe unten |
|
||||||
|
| `core:attack`/`core:flee` implizit gewährt | 02/09 | **bestätigt** — ein Held ohne Klasse kann trotzdem angreifen |
|
||||||
|
|
||||||
|
Zwei Befunde nachgetragen: **02/15** (`CharacterOption.abilities` fehlte — das Argument „ein Magier
|
||||||
|
bleibt ein Magier, weil er zaubern kann" ruhte auf einem Feld, das nie gebaut wurde; eine Klasse konnte
|
||||||
|
nur flache Modifikatoren geben, also genau die Hälfte, die laut demselben Ticket veraltet) und **02/09**
|
||||||
|
(„Held ist noch nicht gebaut" darf **nicht** aus `options.is_empty()` gefolgert werden — ein
|
||||||
|
Speicherstand ohne Rassen-Mod hat nach der Verwerf-Regel ebenfalls keine Optionen und würde neu gebaut
|
||||||
|
statt fortgesetzt).
|
||||||
|
|
||||||
**Drei Befunde aus M1, die nur der Compiler liefern konnte** — alle in die Tickets zurückgetragen:
|
**Drei Befunde aus M1, die nur der Compiler liefern konnte** — alle in die Tickets zurückgetragen:
|
||||||
|
|
||||||
- **11**: Der Grenztest in seiner wörtlichen Fassung schlägt beim eigenen Doc-Kommentar an. Muss Kommentarzeilen ausnehmen.
|
- **11**: Der Grenztest in seiner wörtlichen Fassung schlägt beim eigenen Doc-Kommentar an. Muss Kommentarzeilen ausnehmen.
|
||||||
|
|||||||
@@ -56,3 +56,23 @@ cost = 0
|
|||||||
cooldown = 8
|
cooldown = 8
|
||||||
condition = [ { check = "health_below", who = "self", value = 0.4 } ]
|
condition = [ { check = "health_below", who = "self", value = 0.4 } ]
|
||||||
actions = [ { action = "change_attribute", attribute = "health", amount = 25, target = "self" } ]
|
actions = [ { action = "change_attribute", attribute = "health", amount = 25, target = "self" } ]
|
||||||
|
|
||||||
|
# Die Haelfte einer Charakteroption, die NICHT veraltet (Ticket 15): "+3 Staerke"
|
||||||
|
# ist auf Stufe 30 Rauschen, ein Wuchtschlag nicht.
|
||||||
|
|
||||||
|
[[abilities]]
|
||||||
|
id = "backstab"
|
||||||
|
name = "Hinterhalt"
|
||||||
|
description = "Trifft, wo es wehtut. Nur bei vollem Atem."
|
||||||
|
cost = 12
|
||||||
|
cooldown = 4
|
||||||
|
condition = [ { check = "attribute", who = "self", attribute = "dexterity", at_least = 2 } ]
|
||||||
|
actions = [ { action = "damage", scale = 2.0, damage_type = "slash", target = "target" } ]
|
||||||
|
|
||||||
|
[[abilities]]
|
||||||
|
id = "steinsinn"
|
||||||
|
name = "Steinsinn"
|
||||||
|
description = "Der Zwerg horcht ins Gestein und sammelt sich."
|
||||||
|
cost = 0
|
||||||
|
cooldown = 6
|
||||||
|
actions = [ { action = "change_attribute", attribute = "stamina", amount = 30, target = "self" } ]
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ name = "Zwerg"
|
|||||||
description = "Zäh, misstrauisch, trinkfest."
|
description = "Zäh, misstrauisch, trinkfest."
|
||||||
modifiers = [ { attribute = "constitution", flat = 2 },
|
modifiers = [ { attribute = "constitution", flat = 2 },
|
||||||
{ attribute = "max_health", flat = 10 } ]
|
{ attribute = "max_health", flat = 10 } ]
|
||||||
|
abilities = ["core:steinsinn"]
|
||||||
|
|
||||||
[[options]]
|
[[options]]
|
||||||
id = "elf"
|
id = "elf"
|
||||||
@@ -37,6 +38,12 @@ name = "Krieger"
|
|||||||
description = "Schlägt zu, wo andere nachdenken."
|
description = "Schlägt zu, wo andere nachdenken."
|
||||||
modifiers = [ { attribute = "strength", flat = 3 },
|
modifiers = [ { attribute = "strength", flat = 3 },
|
||||||
{ attribute = "damage", percent = 10 } ]
|
{ attribute = "damage", percent = 10 } ]
|
||||||
|
abilities = ["core:power_strike"]
|
||||||
|
# `on_create` ist KEINE neue Mechanik, sondern gewoehnliche Aktionen, einmal
|
||||||
|
# beim Bau ausgefuehrt (Ticket 02). Die Engine kennt den Begriff
|
||||||
|
# "Startausruestung" deshalb gar nicht.
|
||||||
|
on_create = [ { action = "give_item", item = "core:iron_sword" },
|
||||||
|
{ action = "give_item", item = "core:health_potion", count = 2 } ]
|
||||||
|
|
||||||
[[options]]
|
[[options]]
|
||||||
id = "thief"
|
id = "thief"
|
||||||
@@ -46,3 +53,6 @@ description = "Nimmt, was nicht niet- und nagelfest ist."
|
|||||||
modifiers = [ { attribute = "dexterity", flat = 2 },
|
modifiers = [ { attribute = "dexterity", flat = 2 },
|
||||||
{ attribute = "luck", flat = 2 },
|
{ attribute = "luck", flat = 2 },
|
||||||
{ attribute = "critical_chance", percent = 15 } ]
|
{ attribute = "critical_chance", percent = 15 } ]
|
||||||
|
abilities = ["core:backstab"]
|
||||||
|
on_create = [ { action = "give_item", item = "core:leather_cap" },
|
||||||
|
{ action = "give_item", item = "core:gold", count = 40 } ]
|
||||||
|
|||||||
@@ -97,10 +97,19 @@ pub struct CharacterOption {
|
|||||||
pub description: String,
|
pub description: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub modifiers: Vec<Modifier>,
|
pub modifiers: Vec<Modifier>,
|
||||||
|
/// **Was eine Charakteroption dauerhaft traegt** (Ticket 15): Modifikatoren
|
||||||
|
/// veralten — „Zwerg: +2 Konstitution" ist auf Stufe 30 Rauschen —,
|
||||||
|
/// Faehigkeiten nicht. Ein Magier bleibt ein Magier, weil er zaubern kann.
|
||||||
|
#[serde(default)]
|
||||||
|
pub abilities: Vec<String>,
|
||||||
|
/// **Keine neue Mechanik**, sondern gewoehnliche Aktionen, einmal beim
|
||||||
|
/// Charakterbau ausgefuehrt (Ticket 02). Startgold, Startausruestung und
|
||||||
|
/// Startbuff sind damit moddbar, ohne dass die Engine den Begriff
|
||||||
|
/// „Startausruestung" kennt.
|
||||||
|
#[serde(default)]
|
||||||
|
pub on_create: Vec<Action>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub overrides: Option<String>,
|
pub overrides: Option<String>,
|
||||||
// TODO(M3): `abilities` und `on_create` (Ticket 02) — Faehigkeiten und
|
|
||||||
// Startausruestung brauchen Kampf bzw. Inventar.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ein benannter, befristeter Modifikator — „Vergiftet", „Gesegnet".
|
/// Ein benannter, befristeter Modifikator — „Vergiftet", „Gesegnet".
|
||||||
@@ -520,6 +529,30 @@ impl Registries {
|
|||||||
|
|
||||||
/// Slots in Anzeigereihenfolge. Der Charakterbogen schleift darueber,
|
/// Slots in Anzeigereihenfolge. Der Charakterbogen schleift darueber,
|
||||||
/// statt ein Layout zu mauern (Ticket 02).
|
/// statt ein Layout zu mauern (Ticket 02).
|
||||||
|
/// Wahlkategorien in Anzeigereihenfolge. Der Charakterbau schleift
|
||||||
|
/// darueber, statt ein Layout zu mauern — ein Mod darf „Talent" ergaenzen
|
||||||
|
/// (Ticket 02/11).
|
||||||
|
pub fn categories_ordered(&self) -> Vec<&Category> {
|
||||||
|
let mut v: Vec<&Category> = self.categories.values().collect();
|
||||||
|
v.sort_by(|a, b| (a.order, &a.id).cmp(&(b.order, &b.id)));
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie viele der gewaehlten IDs in diese Kategorie fallen. Die Frage
|
||||||
|
/// stellen `rejects`, der Screen und die Anzeige — also steht sie einmal
|
||||||
|
/// hier und nicht dreimal als Filterkette.
|
||||||
|
pub fn picked_in(&self, category: &str, gewaehlt: &[String]) -> usize {
|
||||||
|
gewaehlt.iter().filter(|id| self.options.get(*id).is_some_and(|o| o.category == category)).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Optionen einer Kategorie, in stabiler Reihenfolge.
|
||||||
|
pub fn options_of(&self, category: &str) -> Vec<&CharacterOption> {
|
||||||
|
let mut v: Vec<&CharacterOption> =
|
||||||
|
self.options.values().filter(|o| o.category == category).collect();
|
||||||
|
v.sort_by(|a, b| a.id.cmp(&b.id));
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
pub fn slots_ordered(&self) -> Vec<&Slot> {
|
pub fn slots_ordered(&self) -> Vec<&Slot> {
|
||||||
let mut v: Vec<&Slot> = self.slots.values().collect();
|
let mut v: Vec<&Slot> = self.slots.values().collect();
|
||||||
v.sort_by_key(|s| (s.order, s.id.clone()));
|
v.sort_by_key(|s| (s.order, s.id.clone()));
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ pub enum Command {
|
|||||||
ability: String,
|
ability: String,
|
||||||
target: super::creature::CreatureRef,
|
target: super::creature::CreatureRef,
|
||||||
},
|
},
|
||||||
|
/// Die gewaehlten Charakteroptionen, eine Liste von IDs. Wie viele je
|
||||||
|
/// Kategorie noetig sind, steht als `pick` in der Registry — der Screen
|
||||||
|
/// zaehlt nicht mit, `rejects` prueft.
|
||||||
|
CreateCharacter(Vec<String>),
|
||||||
/// **Alle** Punkte auf einmal und damit **ein** Zug (Ticket 15). Ein
|
/// **Alle** Punkte auf einmal und damit **ein** Zug (Ticket 15). Ein
|
||||||
/// Kommando je Punkt hiesse bei drei Stufen drei Zuege, in denen ein Gift
|
/// Kommando je Punkt hiesse bei drei Stufen drei Zuege, in denen ein Gift
|
||||||
/// tickt, waehrend man ein Menue bedient.
|
/// tickt, waehrend man ein Menue bedient.
|
||||||
|
|||||||
@@ -738,6 +738,15 @@ impl Game {
|
|||||||
// Sein eigenes Gold zurueckzuverkaufen ergibt keinen Sinn.
|
// Sein eigenes Gold zurueckzuverkaufen ergibt keinen Sinn.
|
||||||
Command::Sell(i) => (self.player.inventory[*i].def == "core:gold")
|
Command::Sell(i) => (self.player.inventory[*i].def == "core:gold")
|
||||||
.then_some("Gold verkauft man nicht"),
|
.then_some("Gold verkauft man nicht"),
|
||||||
|
Command::CreateCharacter(wahl) => {
|
||||||
|
// Genau `pick` je Kategorie — die Zahl steht in der Registry,
|
||||||
|
// nicht im Screen (Ticket 02).
|
||||||
|
let fehlt = reg
|
||||||
|
.categories_ordered()
|
||||||
|
.iter()
|
||||||
|
.any(|c| reg.picked_in(&c.id, wahl) != c.pick as usize);
|
||||||
|
fehlt.then_some("Wahl unvollständig")
|
||||||
|
}
|
||||||
Command::AssignPoints(punkte) => {
|
Command::AssignPoints(punkte) => {
|
||||||
let summe: u32 = punkte.iter().map(|(_, n)| n).sum();
|
let summe: u32 = punkte.iter().map(|(_, n)| n).sum();
|
||||||
if summe == 0 {
|
if summe == 0 {
|
||||||
@@ -824,8 +833,12 @@ impl Game {
|
|||||||
out.extend(item.abilities.iter().cloned());
|
out.extend(item.abilities.iter().cloned());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// TODO(M5): Faehigkeiten aus Charakteroptionen — die Registry braucht
|
// Rasse und Klasse. Das ist die Haelfte, die nicht veraltet (Ticket 15).
|
||||||
// dafuer erst ein `abilities`-Feld (Ticket 02).
|
for id in &self.creature(who).options {
|
||||||
|
if let Some(o) = reg.options.get(id) {
|
||||||
|
out.extend(o.abilities.iter().cloned());
|
||||||
|
}
|
||||||
|
}
|
||||||
out.retain(|id| reg.abilities.contains_key(id));
|
out.retain(|id| reg.abilities.contains_key(id));
|
||||||
out.dedup();
|
out.dedup();
|
||||||
out
|
out
|
||||||
@@ -895,11 +908,12 @@ impl Game {
|
|||||||
Command::PickUp => self.pick_up(reg),
|
Command::PickUp => self.pick_up(reg),
|
||||||
Command::UseItem(i) => self.use_item(*i, reg),
|
Command::UseItem(i) => self.use_item(*i, reg),
|
||||||
Command::Equip(i) => self.equip(*i, reg),
|
Command::Equip(i) => self.equip(*i, reg),
|
||||||
Command::Unequip(slot) => self.unequip(&slot.clone(), reg),
|
Command::Unequip(slot) => self.unequip(slot, reg),
|
||||||
Command::Talk => self.talk(reg),
|
Command::Talk => self.talk(reg),
|
||||||
Command::Choose(i) => self.choose(*i, reg),
|
Command::Choose(i) => self.choose(*i, reg),
|
||||||
Command::Buy(i) => self.buy(*i, reg),
|
Command::Buy(i) => self.buy(*i, reg),
|
||||||
Command::Sell(i) => self.sell(*i, reg),
|
Command::Sell(i) => self.sell(*i, reg),
|
||||||
|
Command::CreateCharacter(wahl) => self.create_character(wahl, reg),
|
||||||
Command::AssignPoints(punkte) => self.assign_points(punkte, reg),
|
Command::AssignPoints(punkte) => self.assign_points(punkte, reg),
|
||||||
Command::Debug(cheat) => self.cheat(cheat, reg),
|
Command::Debug(cheat) => self.cheat(cheat, reg),
|
||||||
Command::Drop(i) => {
|
Command::Drop(i) => {
|
||||||
@@ -994,6 +1008,28 @@ impl Game {
|
|||||||
self.log(format!("{name} verkauft fuer {preis} Gold"));
|
self.log(format!("{name} verkauft fuer {preis} Gold"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Legt die gewaehlten Optionen an und fuehrt ihre `on_create`-Aktionen
|
||||||
|
/// aus. Beides einmal, beim Bau des Helden.
|
||||||
|
fn create_character(&mut self, wahl: &[String], reg: &Registries) {
|
||||||
|
self.player.options = wahl.to_vec();
|
||||||
|
|
||||||
|
// Erst die Optionen setzen, dann heilen: `max_health` haengt an ihren
|
||||||
|
// Modifikatoren, und der frische Held soll voll starten.
|
||||||
|
let p = CreatureRef::Player;
|
||||||
|
self.player.health = self.attribute(p, Attribute::MaxHealth, None, reg);
|
||||||
|
self.player.stamina = self.attribute(p, Attribute::MaxStamina, None, reg);
|
||||||
|
|
||||||
|
for id in wahl {
|
||||||
|
let Some(o) = reg.options.get(id) else { continue };
|
||||||
|
for action in &o.on_create {
|
||||||
|
self.apply(action, p, p, reg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let namen: Vec<&str> =
|
||||||
|
wahl.iter().filter_map(|id| reg.options.get(id)).map(|o| o.name.as_str()).collect();
|
||||||
|
self.log(format!("Willkommen, {}.", namen.join(" ")));
|
||||||
|
}
|
||||||
|
|
||||||
/// Was der naechste Aufstieg kostet: `exp_base × level^exp_exponent`.
|
/// Was der naechste Aufstieg kostet: `exp_base × level^exp_exponent`.
|
||||||
pub fn exp_needed(&self, reg: &Registries) -> u32 {
|
pub fn exp_needed(&self, reg: &Registries) -> u32 {
|
||||||
let level = self.player.base.get(Attribute::Level).max(1.0);
|
let level = self.player.base.get(Attribute::Level).max(1.0);
|
||||||
@@ -2129,4 +2165,53 @@ mod tests {
|
|||||||
assert_eq!(game.quests[0].count, 5, "der Zaehler laeuft nicht weiter");
|
assert_eq!(game.quests[0].count, 5, "der Zaehler laeuft nicht weiter");
|
||||||
assert_eq!(game.gold(), gold_vorher + 60);
|
assert_eq!(game.gold(), gold_vorher + 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Der Charakterbau als Ganzes: `pick` je Kategorie wird erzwungen, die
|
||||||
|
/// Optionen wirken, ihre `on_create`-Aktionen laufen einmal — und die
|
||||||
|
/// Faehigkeit, die eine Klasse traegt, steht danach im Kampfmenue. Genau
|
||||||
|
/// das ist die Haelfte, die laut Ticket 15 nicht veraltet.
|
||||||
|
#[test]
|
||||||
|
fn ein_held_wird_aus_der_registry_gebaut() {
|
||||||
|
let (reg, _) = load_mods().expect("core muss laden");
|
||||||
|
let mut game = Game::new(Creature::new("Testheld"), ®, Rng::new(3))
|
||||||
|
.expect("Etage 1");
|
||||||
|
|
||||||
|
// `pick = 1` je Kategorie: eine halbe Wahl wird abgewiesen.
|
||||||
|
let halb = Command::CreateCharacter(vec![String::from("core:dwarf")]);
|
||||||
|
assert!(matches!(game.execute(halb, ®), Outcome::Rejected(_)));
|
||||||
|
assert!(game.player.options.is_empty(), "eine Abweisung baut nichts");
|
||||||
|
|
||||||
|
// Zwei aus derselben Kategorie ebenso.
|
||||||
|
let doppelt = Command::CreateCharacter(vec![
|
||||||
|
String::from("core:dwarf"),
|
||||||
|
String::from("core:elf"),
|
||||||
|
]);
|
||||||
|
assert!(matches!(game.execute(doppelt, ®), Outcome::Rejected(_)));
|
||||||
|
|
||||||
|
let voll = Command::CreateCharacter(vec![
|
||||||
|
String::from("core:dwarf"),
|
||||||
|
String::from("core:warrior"),
|
||||||
|
]);
|
||||||
|
assert!(matches!(game.execute(voll, ®), Outcome::TurnPassed));
|
||||||
|
|
||||||
|
let p = CreatureRef::Player;
|
||||||
|
assert_eq!(game.player.options.len(), 2);
|
||||||
|
// Zwerg: +2 Konstitution ueber dem Standard von 1.
|
||||||
|
assert_eq!(game.attribute(p, Attribute::Constitution, None, ®), 3.0);
|
||||||
|
assert_eq!(
|
||||||
|
game.player.health,
|
||||||
|
game.attribute(p, Attribute::MaxHealth, None, ®),
|
||||||
|
"ein frischer Held startet voll"
|
||||||
|
);
|
||||||
|
|
||||||
|
// `on_create` hat die Ausruestung gegeben — als gewoehnliche Aktionen.
|
||||||
|
assert_eq!(game.count_of("core:iron_sword"), 1);
|
||||||
|
assert_eq!(game.count_of("core:health_potion"), 2, "der Stapel kommt als Stapel");
|
||||||
|
|
||||||
|
// Die Faehigkeiten von Rasse *und* Klasse stehen im Kampfmenue.
|
||||||
|
let koennen = game.abilities_of(p, ®);
|
||||||
|
assert!(koennen.iter().any(|a| a == "core:steinsinn"), "Rasse: {koennen:?}");
|
||||||
|
assert!(koennen.iter().any(|a| a == "core:power_strike"), "Klasse: {koennen:?}");
|
||||||
|
assert!(koennen.iter().any(|a| a == "core:attack"), "und die impliziten bleiben");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
src/main.rs
22
src/main.rs
@@ -13,7 +13,7 @@ mod data;
|
|||||||
mod game;
|
mod game;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|
||||||
use game::{Creature, ItemInstance};
|
use game::Creature;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
// Die `Result`-Kette endet **vor** `main` (Ticket 04): `main() -> Result<…>`
|
// Die `Result`-Kette endet **vor** `main` (Ticket 04): `main() -> Result<…>`
|
||||||
@@ -45,6 +45,7 @@ fn main() {
|
|||||||
|
|
||||||
// Der Seed wird genau einmal frisch gezogen. Ab hier steht er im Spiel und
|
// Der Seed wird genau einmal frisch gezogen. Ab hier steht er im Spiel und
|
||||||
// spaeter im Speicherstand — Nachladen wuerfelt nicht neu (Ticket 14).
|
// spaeter im Speicherstand — Nachladen wuerfelt nicht neu (Ticket 14).
|
||||||
|
let neu = geladen.is_none();
|
||||||
let Some(game) =
|
let Some(game) =
|
||||||
geladen.or_else(|| game::Game::new(starting_hero(), ®istries, game::Rng::from_clock()))
|
geladen.or_else(|| game::Game::new(starting_hero(), ®istries, game::Rng::from_clock()))
|
||||||
else {
|
else {
|
||||||
@@ -54,22 +55,19 @@ fn main() {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = ui::run(game, registries, warnings.len(), rueckfragen, log_name) {
|
if let Err(e) = ui::run(game, registries, warnings.len(), rueckfragen, log_name, neu) {
|
||||||
eprintln!("Terminal-Fehler: {e}");
|
eprintln!("Terminal-Fehler: {e}");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Vorgefertigter Held, damit der Durchstich etwas zu zeigen hat.
|
/// Ein **leerer** Held: Rasse, Klasse und Startausruestung kommen aus dem
|
||||||
|
/// Charakterbau, nicht von hier. Ausruestung anzulegen war bis M8 der Job
|
||||||
|
/// dieser Funktion — jetzt macht es `on_create` an der Charakteroption, und
|
||||||
|
/// damit kann ein Mod es aendern (Ticket 02).
|
||||||
///
|
///
|
||||||
// TODO(M2): faellt weg, sobald der `CharacterCreation`-Screen aus Ticket 11
|
// TODO(M9+): Der Name ist fest. Ihn eingeben zu lassen braucht ein
|
||||||
// existiert — dort waehlt der Spieler die Optionen selbst, und
|
// Texteingabefeld, das es sonst nirgends im Spiel gibt.
|
||||||
// `on_create` (Ticket 02) legt die Startausruestung an.
|
|
||||||
fn starting_hero() -> Creature {
|
fn starting_hero() -> Creature {
|
||||||
let mut hero = Creature::new("Reya");
|
Creature::new("Reya")
|
||||||
hero.options = vec![String::from("core:dwarf"), String::from("core:warrior")];
|
|
||||||
hero.equipment.insert(String::from("core:hand"), ItemInstance::new("core:iron_sword"));
|
|
||||||
hero.equipment.insert(String::from("core:head"), ItemInstance::new("core:leather_cap"));
|
|
||||||
hero.inventory.push(ItemInstance::new("core:health_potion"));
|
|
||||||
hero
|
|
||||||
}
|
}
|
||||||
|
|||||||
118
src/ui/app.rs
118
src/ui/app.rs
@@ -66,6 +66,14 @@ impl App {
|
|||||||
self.screens.push(Screen::Shop { pick: 0, selling: false });
|
self.screens.push(Screen::Shop { pick: 0, selling: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Der Charakterbau ist fertig, sobald der Held Optionen hat — dieselbe
|
||||||
|
// Regel wie beim Kampf: Der Spielzustand fuehrt, der Stack folgt.
|
||||||
|
let baut = self.screens.iter().any(|s| matches!(s, Screen::CharacterCreation { .. }));
|
||||||
|
if baut && !self.game.player.options.is_empty() {
|
||||||
|
self.screens.retain(|s| !matches!(s, Screen::CharacterCreation { .. }));
|
||||||
|
self.screens.push(Screen::Explore);
|
||||||
|
}
|
||||||
|
|
||||||
if self.game.over.is_some()
|
if self.game.over.is_some()
|
||||||
&& !self.screens.iter().any(|s| matches!(s, Screen::GameOver))
|
&& !self.screens.iter().any(|s| matches!(s, Screen::GameOver))
|
||||||
{
|
{
|
||||||
@@ -80,8 +88,9 @@ pub fn run(
|
|||||||
warnings: usize,
|
warnings: usize,
|
||||||
verworfen: usize,
|
verworfen: usize,
|
||||||
log_name: Option<String>,
|
log_name: Option<String>,
|
||||||
|
neu: bool,
|
||||||
) -> std::io::Result<()> {
|
) -> std::io::Result<()> {
|
||||||
let mut screens = vec![Screen::MainMenu];
|
let mut screens = vec![Screen::MainMenu { neu }];
|
||||||
|
|
||||||
// Die Rueckfrage steht **vor** dem Spiel, nicht als Zeile darin (Ticket
|
// Die Rueckfrage steht **vor** dem Spiel, nicht als Zeile darin (Ticket
|
||||||
// 09): Wer hier „nein" sagt, will den fehlenden Mod nachinstallieren — und
|
// 09): Wer hier „nein" sagt, will den fehlenden Mod nachinstallieren — und
|
||||||
@@ -180,7 +189,7 @@ fn draw(frame: &mut Frame, app: &App) {
|
|||||||
|
|
||||||
for screen in &app.screens[first..] {
|
for screen in &app.screens[first..] {
|
||||||
match screen {
|
match screen {
|
||||||
Screen::MainMenu => draw_main_menu(frame, area, app),
|
Screen::MainMenu { neu } => draw_main_menu(frame, area, app, *neu),
|
||||||
Screen::Explore => draw_explore(frame, area, app),
|
Screen::Explore => draw_explore(frame, area, app),
|
||||||
Screen::Combat { pick, target, targeting } => {
|
Screen::Combat { pick, target, targeting } => {
|
||||||
draw_combat(frame, area, app, *pick, *target, *targeting)
|
draw_combat(frame, area, app, *pick, *target, *targeting)
|
||||||
@@ -191,6 +200,9 @@ fn draw(frame: &mut Frame, app: &App) {
|
|||||||
}
|
}
|
||||||
Screen::Dialog { pick } => draw_dialog(frame, area, app, *pick),
|
Screen::Dialog { pick } => draw_dialog(frame, area, app, *pick),
|
||||||
Screen::Shop { pick, selling } => draw_shop(frame, area, app, *pick, *selling),
|
Screen::Shop { pick, selling } => draw_shop(frame, area, app, *pick, *selling),
|
||||||
|
Screen::CharacterCreation { kategorie, pick, gewaehlt } => {
|
||||||
|
draw_creation(frame, area, app, *kategorie, *pick, gewaehlt)
|
||||||
|
}
|
||||||
Screen::QuestLog => draw_quests(frame, area, app),
|
Screen::QuestLog => draw_quests(frame, area, app),
|
||||||
Screen::Confirm { text, .. } => draw_confirm(frame, area, text),
|
Screen::Confirm { text, .. } => draw_confirm(frame, area, text),
|
||||||
Screen::Debug { pick } => draw_debug(frame, area, app, *pick),
|
Screen::Debug { pick } => draw_debug(frame, area, app, *pick),
|
||||||
@@ -199,13 +211,26 @@ fn draw(frame: &mut Frame, app: &App) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_main_menu(frame: &mut Frame, area: Rect, app: &App) {
|
fn draw_main_menu(frame: &mut Frame, area: Rect, app: &App, neu: bool) {
|
||||||
let mut text = vec![
|
let mut text = vec![
|
||||||
Line::from("rustkeep".bold()),
|
Line::from("rustkeep".bold()),
|
||||||
Line::from(""),
|
Line::from(""),
|
||||||
Line::from("Enter Dungeon betreten"),
|
Line::from(match neu {
|
||||||
|
true => "Enter neuen Helden erschaffen",
|
||||||
|
false => "Enter Lauf fortsetzen",
|
||||||
|
}),
|
||||||
Line::from("q beenden"),
|
Line::from("q beenden"),
|
||||||
];
|
];
|
||||||
|
if !neu {
|
||||||
|
text.insert(
|
||||||
|
2,
|
||||||
|
Line::from(format!(
|
||||||
|
"Etage {} · Zug {}",
|
||||||
|
app.game.floor.number, app.game.turn
|
||||||
|
))
|
||||||
|
.style(Style::new().fg(Color::DarkGray)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Eine Zeile, kein Screen — der Inhalt gehoert in einen Texteditor
|
// Eine Zeile, kein Screen — der Inhalt gehoert in einen Texteditor
|
||||||
// (Ticket 04).
|
// (Ticket 04).
|
||||||
@@ -399,6 +424,91 @@ fn draw_shop(frame: &mut Frame, area: Rect, app: &App, pick: usize, selling: boo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Links die Kategorien aus der Registry, rechts ihre Optionen — beides
|
||||||
|
/// geschleift, nichts gemauert (Ticket 02/11).
|
||||||
|
fn draw_creation(
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
app: &App,
|
||||||
|
kategorie: usize,
|
||||||
|
pick: usize,
|
||||||
|
gewaehlt: &[String],
|
||||||
|
) {
|
||||||
|
let block = Block::bordered().title(" Wer bist du? ");
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
let [links, rechts] =
|
||||||
|
Layout::horizontal([Constraint::Percentage(35), Constraint::Min(0)]).areas(inner);
|
||||||
|
|
||||||
|
let kategorien = app.registries.categories_ordered();
|
||||||
|
let mut spalte = Vec::new();
|
||||||
|
for (i, k) in kategorien.iter().enumerate() {
|
||||||
|
let hier = app.registries.picked_in(&k.id, gewaehlt);
|
||||||
|
let aktiv = i == kategorie;
|
||||||
|
spalte.push(
|
||||||
|
Line::from(format!(
|
||||||
|
"{} {:<14} {hier}/{}",
|
||||||
|
if aktiv { "›" } else { " " },
|
||||||
|
k.name,
|
||||||
|
k.pick
|
||||||
|
))
|
||||||
|
.style(Style::new().fg(if aktiv { Color::Yellow } else { Color::Reset })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(spalte).block(Block::bordered().title(" Wahl ")),
|
||||||
|
links,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut zeilen = Vec::new();
|
||||||
|
if let Some(k) = kategorien.get(kategorie) {
|
||||||
|
for (i, o) in app.registries.options_of(&k.id).iter().enumerate() {
|
||||||
|
let an = gewaehlt.contains(&o.id);
|
||||||
|
let zeiger = if i == pick { "›" } else { " " };
|
||||||
|
zeilen.push(
|
||||||
|
Line::from(format!("{zeiger} [{}] {}", if an { "x" } else { " " }, o.name)).style(
|
||||||
|
Style::new().fg(match (an, i == pick) {
|
||||||
|
(true, _) => Color::Green,
|
||||||
|
(false, true) => Color::Yellow,
|
||||||
|
_ => Color::Reset,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if i == pick {
|
||||||
|
if !o.description.is_empty() {
|
||||||
|
zeilen.push(
|
||||||
|
Line::from(format!(" {}", o.description))
|
||||||
|
.style(Style::new().fg(Color::DarkGray)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for m in &o.modifiers {
|
||||||
|
zeilen.push(
|
||||||
|
Line::from(format!(" {} {:+}", m.attribute.name(), m.flat as i32))
|
||||||
|
.style(Style::new().fg(Color::Cyan)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for a in &o.abilities {
|
||||||
|
let name =
|
||||||
|
app.registries.abilities.get(a).map(|d| d.name.as_str()).unwrap_or(a);
|
||||||
|
zeilen.push(
|
||||||
|
Line::from(format!(" Fähigkeit: {name}"))
|
||||||
|
.style(Style::new().fg(Color::Magenta)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
zeilen.push(Line::from(""));
|
||||||
|
zeilen.push(
|
||||||
|
Line::from("↑↓ wählen · Enter nehmen · Tab Kategorie")
|
||||||
|
.style(Style::new().fg(Color::DarkGray)),
|
||||||
|
);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(zeilen).block(Block::bordered().title(" Optionen ")),
|
||||||
|
rechts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Laufende oben, erledigte darunter — erledigte bleiben stehen, weil sie es
|
/// Laufende oben, erledigte darunter — erledigte bleiben stehen, weil sie es
|
||||||
/// auch im Spielzustand tun (Ticket 08).
|
/// auch im Spielzustand tun (Ticket 08).
|
||||||
fn draw_quests(frame: &mut Frame, area: Rect, app: &App) {
|
fn draw_quests(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ use crate::game::{Attribute, Cheat, Command, CreatureRef, Direction, Game};
|
|||||||
/// `Option<Popup>`, weil Popup ueber Popup real ist.
|
/// `Option<Popup>`, weil Popup ueber Popup real ist.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub enum Screen {
|
pub enum Screen {
|
||||||
MainMenu,
|
/// `neu` entscheidet, wohin Enter fuehrt — der Screen weiss es, weil `main`
|
||||||
|
/// es weiss. Es aus `player.options.is_empty()` zu folgern waere kuerzer und
|
||||||
|
/// falsch: Ein Speicherstand, dem der Rassen-Mod fehlt, hat auch keine
|
||||||
|
/// Optionen und wuerde neu gebaut statt fortgesetzt.
|
||||||
|
MainMenu { neu: bool },
|
||||||
Explore,
|
Explore,
|
||||||
/// Menue-Kampf (Charter): Faehigkeitsliste links, Gegnerliste rechts.
|
/// Menue-Kampf (Charter): Faehigkeitsliste links, Gegnerliste rechts.
|
||||||
Combat {
|
Combat {
|
||||||
@@ -39,6 +43,17 @@ pub enum Screen {
|
|||||||
/// Meldungszeile im Hauptmenue: Ein Abstiegs-Roguelike lebt davon, dass der
|
/// Meldungszeile im Hauptmenue: Ein Abstiegs-Roguelike lebt davon, dass der
|
||||||
/// Lauf am Ende *erzaehlt* wird (Ticket 14).
|
/// Lauf am Ende *erzaehlt* wird (Ticket 14).
|
||||||
GameOver,
|
GameOver,
|
||||||
|
/// Charakterbau. **Eine Schleife ueber die Registry**, kein gemauertes
|
||||||
|
/// Layout (Ticket 02/11): Ein Mod, der „Talent" als dritte Kategorie
|
||||||
|
/// ergaenzt, bekommt sie hier ohne eine Zeile Code.
|
||||||
|
CharacterCreation {
|
||||||
|
/// Welche Kategorie gerade dran ist.
|
||||||
|
kategorie: usize,
|
||||||
|
/// Cursor in deren Optionsliste.
|
||||||
|
pick: usize,
|
||||||
|
/// Bisher Gewaehltes, als IDs.
|
||||||
|
gewaehlt: Vec<String>,
|
||||||
|
},
|
||||||
/// Das Questlog. Reine Anzeige — es gibt nichts zu waehlen, weil eine
|
/// Das Questlog. Reine Anzeige — es gibt nichts zu waehlen, weil eine
|
||||||
/// Quest keine Handlung kennt, die der Spieler hier ausloest.
|
/// Quest keine Handlung kennt, die der Spieler hier ausloest.
|
||||||
QuestLog,
|
QuestLog,
|
||||||
@@ -104,7 +119,12 @@ impl Screen {
|
|||||||
/// nicht an" bleibt, weil nur `Response::Command` etwas aendert.
|
/// nicht an" bleibt, weil nur `Response::Command` etwas aendert.
|
||||||
pub fn handle_key(&mut self, key: KeyCode, game: &Game, reg: &Registries) -> Response {
|
pub fn handle_key(&mut self, key: KeyCode, game: &Game, reg: &Registries) -> Response {
|
||||||
match self {
|
match self {
|
||||||
Screen::MainMenu => match key {
|
Screen::MainMenu { neu } => match key {
|
||||||
|
KeyCode::Enter if *neu => Response::Push(Screen::CharacterCreation {
|
||||||
|
kategorie: 0,
|
||||||
|
pick: 0,
|
||||||
|
gewaehlt: Vec::new(),
|
||||||
|
}),
|
||||||
KeyCode::Enter => Response::Push(Screen::Explore),
|
KeyCode::Enter => Response::Push(Screen::Explore),
|
||||||
KeyCode::Char('q') | KeyCode::Esc => Response::Quit,
|
KeyCode::Char('q') | KeyCode::Esc => Response::Quit,
|
||||||
_ => Response::None,
|
_ => Response::None,
|
||||||
@@ -360,6 +380,60 @@ impl Screen {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Screen::CharacterCreation { kategorie, pick, gewaehlt } => {
|
||||||
|
let kategorien = reg.categories_ordered();
|
||||||
|
let Some(k) = kategorien.get(*kategorie) else { return Response::None };
|
||||||
|
let optionen = reg.options_of(&k.id);
|
||||||
|
if optionen.is_empty() {
|
||||||
|
return Response::None;
|
||||||
|
}
|
||||||
|
*pick = (*pick).min(optionen.len() - 1);
|
||||||
|
|
||||||
|
match key {
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
|
*pick = pick.saturating_sub(1);
|
||||||
|
Response::None
|
||||||
|
}
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
|
*pick = (*pick + 1).min(optionen.len() - 1);
|
||||||
|
Response::None
|
||||||
|
}
|
||||||
|
// Auswaehlen schaltet um: nochmal druecken nimmt zurueck.
|
||||||
|
KeyCode::Char(' ') | KeyCode::Enter => {
|
||||||
|
let id = optionen[*pick].id.clone();
|
||||||
|
if let Some(i) = gewaehlt.iter().position(|g| *g == id) {
|
||||||
|
gewaehlt.remove(i);
|
||||||
|
} else if reg.picked_in(&k.id, gewaehlt) < k.pick as usize {
|
||||||
|
gewaehlt.push(id);
|
||||||
|
}
|
||||||
|
// Ist die Kategorie voll, weiter zur naechsten — und bei
|
||||||
|
// der letzten steht der Held.
|
||||||
|
if reg.picked_in(&k.id, gewaehlt) == k.pick as usize {
|
||||||
|
if *kategorie + 1 < kategorien.len() {
|
||||||
|
*kategorie += 1;
|
||||||
|
*pick = 0;
|
||||||
|
} else {
|
||||||
|
return Response::Command(Command::CreateCharacter(
|
||||||
|
gewaehlt.clone(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Response::None
|
||||||
|
}
|
||||||
|
KeyCode::Tab | KeyCode::Right => {
|
||||||
|
*kategorie = (*kategorie + 1).min(kategorien.len() - 1);
|
||||||
|
*pick = 0;
|
||||||
|
Response::None
|
||||||
|
}
|
||||||
|
KeyCode::BackTab | KeyCode::Left => {
|
||||||
|
*kategorie = kategorie.saturating_sub(1);
|
||||||
|
*pick = 0;
|
||||||
|
Response::None
|
||||||
|
}
|
||||||
|
_ => Response::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Screen::QuestLog => match key {
|
Screen::QuestLog => match key {
|
||||||
KeyCode::Esc | KeyCode::Char('q') => Response::Pop,
|
KeyCode::Esc | KeyCode::Char('q') => Response::Pop,
|
||||||
_ => Response::None,
|
_ => Response::None,
|
||||||
|
|||||||
Reference in New Issue
Block a user