Merge branch 'feat/m8-charakterbau'

This commit is contained in:
2026-08-13 08:07:40 +02:00
10 changed files with 403 additions and 23 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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" } ]

View File

@@ -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 } ]

View File

@@ -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()));

View File

@@ -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.

View File

@@ -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"), &reg, 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, &reg), 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, &reg), Outcome::Rejected(_)));
let voll = Command::CreateCharacter(vec![
String::from("core:dwarf"),
String::from("core:warrior"),
]);
assert!(matches!(game.execute(voll, &reg), 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, &reg), 3.0);
assert_eq!(
game.player.health,
game.attribute(p, Attribute::MaxHealth, None, &reg),
"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, &reg);
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");
}
} }

View File

@@ -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(), &registries, game::Rng::from_clock())) geladen.or_else(|| game::Game::new(starting_hero(), &registries, 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
} }

View File

@@ -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) {

View File

@@ -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,