diff --git a/.scratch/rustkeep-1.0/issues/02-typ-registry.md b/.scratch/rustkeep-1.0/issues/02-typ-registry.md index 1f9af59..92186a7 100644 --- a/.scratch/rustkeep-1.0/issues/02-typ-registry.md +++ b/.scratch/rustkeep-1.0/issues/02-typ-registry.md @@ -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, `weapons.toml` und `armors.toml` verschmelzen zu einer gewöhnlichen Gegenstandsdatei, `items.rs` 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. diff --git a/.scratch/rustkeep-1.0/map.md b/.scratch/rustkeep-1.0/map.md index 5767c07..31b548c 100644 --- a/.scratch/rustkeep-1.0/map.md +++ b/.scratch/rustkeep-1.0/map.md @@ -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 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: - **11**: Der Grenztest in seiner wörtlichen Fassung schlägt beim eigenen Doc-Kommentar an. Muss Kommentarzeilen ausnehmen. diff --git a/mods/core/abilities.toml b/mods/core/abilities.toml index 181f4e5..0a12f27 100644 --- a/mods/core/abilities.toml +++ b/mods/core/abilities.toml @@ -56,3 +56,23 @@ cost = 0 cooldown = 8 condition = [ { check = "health_below", who = "self", value = 0.4 } ] 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" } ] diff --git a/mods/core/character_options.toml b/mods/core/character_options.toml index 3d65948..40c0328 100644 --- a/mods/core/character_options.toml +++ b/mods/core/character_options.toml @@ -21,6 +21,7 @@ name = "Zwerg" description = "Zäh, misstrauisch, trinkfest." modifiers = [ { attribute = "constitution", flat = 2 }, { attribute = "max_health", flat = 10 } ] +abilities = ["core:steinsinn"] [[options]] id = "elf" @@ -37,6 +38,12 @@ name = "Krieger" description = "Schlägt zu, wo andere nachdenken." modifiers = [ { attribute = "strength", flat = 3 }, { 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]] id = "thief" @@ -46,3 +53,6 @@ description = "Nimmt, was nicht niet- und nagelfest ist." modifiers = [ { attribute = "dexterity", flat = 2 }, { attribute = "luck", flat = 2 }, { 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 } ] diff --git a/src/data/registries.rs b/src/data/registries.rs index 2280808..b355903 100644 --- a/src/data/registries.rs +++ b/src/data/registries.rs @@ -97,10 +97,19 @@ pub struct CharacterOption { pub description: String, #[serde(default)] pub modifiers: Vec, + /// **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, + /// **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, #[serde(default)] pub overrides: Option, - // TODO(M3): `abilities` und `on_create` (Ticket 02) — Faehigkeiten und - // Startausruestung brauchen Kampf bzw. Inventar. } /// Ein benannter, befristeter Modifikator — „Vergiftet", „Gesegnet". @@ -520,6 +529,30 @@ impl Registries { /// Slots in Anzeigereihenfolge. Der Charakterbogen schleift darueber, /// 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> { let mut v: Vec<&Slot> = self.slots.values().collect(); v.sort_by_key(|s| (s.order, s.id.clone())); diff --git a/src/game/command.rs b/src/game/command.rs index f6f28ee..50498f9 100644 --- a/src/game/command.rs +++ b/src/game/command.rs @@ -43,6 +43,10 @@ pub enum Command { ability: String, 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), /// **Alle** Punkte auf einmal und damit **ein** Zug (Ticket 15). Ein /// Kommando je Punkt hiesse bei drei Stufen drei Zuege, in denen ein Gift /// tickt, waehrend man ein Menue bedient. diff --git a/src/game/mod.rs b/src/game/mod.rs index a60271c..1646602 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -738,6 +738,15 @@ impl Game { // Sein eigenes Gold zurueckzuverkaufen ergibt keinen Sinn. Command::Sell(i) => (self.player.inventory[*i].def == "core:gold") .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) => { let summe: u32 = punkte.iter().map(|(_, n)| n).sum(); if summe == 0 { @@ -824,8 +833,12 @@ impl Game { out.extend(item.abilities.iter().cloned()); } } - // TODO(M5): Faehigkeiten aus Charakteroptionen — die Registry braucht - // dafuer erst ein `abilities`-Feld (Ticket 02). + // Rasse und Klasse. Das ist die Haelfte, die nicht veraltet (Ticket 15). + 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.dedup(); out @@ -895,11 +908,12 @@ impl Game { 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(), reg), + Command::Unequip(slot) => self.unequip(slot, reg), Command::Talk => self.talk(reg), Command::Choose(i) => self.choose(*i, reg), Command::Buy(i) => self.buy(*i, reg), Command::Sell(i) => self.sell(*i, reg), + Command::CreateCharacter(wahl) => self.create_character(wahl, reg), Command::AssignPoints(punkte) => self.assign_points(punkte, reg), Command::Debug(cheat) => self.cheat(cheat, reg), Command::Drop(i) => { @@ -994,6 +1008,28 @@ impl Game { 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`. pub fn exp_needed(&self, reg: &Registries) -> u32 { 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.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"); + } } diff --git a/src/main.rs b/src/main.rs index edc68aa..191c55d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,7 @@ mod data; mod game; mod ui; -use game::{Creature, ItemInstance}; +use game::Creature; fn main() { // 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 // spaeter im Speicherstand — Nachladen wuerfelt nicht neu (Ticket 14). + let neu = geladen.is_none(); let Some(game) = geladen.or_else(|| game::Game::new(starting_hero(), ®istries, game::Rng::from_clock())) else { @@ -54,22 +55,19 @@ fn main() { 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}"); 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 -// existiert — dort waehlt der Spieler die Optionen selbst, und -// `on_create` (Ticket 02) legt die Startausruestung an. +// TODO(M9+): Der Name ist fest. Ihn eingeben zu lassen braucht ein +// Texteingabefeld, das es sonst nirgends im Spiel gibt. fn starting_hero() -> Creature { - let mut hero = 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 + Creature::new("Reya") } diff --git a/src/ui/app.rs b/src/ui/app.rs index 0c79280..c8675c7 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -66,6 +66,14 @@ impl App { 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() && !self.screens.iter().any(|s| matches!(s, Screen::GameOver)) { @@ -80,8 +88,9 @@ pub fn run( warnings: usize, verworfen: usize, log_name: Option, + neu: bool, ) -> 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 // 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..] { 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::Combat { 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::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::Confirm { text, .. } => draw_confirm(frame, area, text), 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![ Line::from("rustkeep".bold()), Line::from(""), - Line::from("Enter Dungeon betreten"), + Line::from(match neu { + true => "Enter neuen Helden erschaffen", + false => "Enter Lauf fortsetzen", + }), 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 // (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 /// auch im Spielzustand tun (Ticket 08). fn draw_quests(frame: &mut Frame, area: Rect, app: &App) { diff --git a/src/ui/screen.rs b/src/ui/screen.rs index daa07b5..5ba7d99 100644 --- a/src/ui/screen.rs +++ b/src/ui/screen.rs @@ -10,7 +10,11 @@ use crate::game::{Attribute, Cheat, Command, CreatureRef, Direction, Game}; /// `Option`, weil Popup ueber Popup real ist. #[derive(Clone)] 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, /// Menue-Kampf (Charter): Faehigkeitsliste links, Gegnerliste rechts. Combat { @@ -39,6 +43,17 @@ pub enum Screen { /// Meldungszeile im Hauptmenue: Ein Abstiegs-Roguelike lebt davon, dass der /// Lauf am Ende *erzaehlt* wird (Ticket 14). 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, + }, /// Das Questlog. Reine Anzeige — es gibt nichts zu waehlen, weil eine /// Quest keine Handlung kennt, die der Spieler hier ausloest. QuestLog, @@ -104,7 +119,12 @@ impl Screen { /// nicht an" bleibt, weil nur `Response::Command` etwas aendert. pub fn handle_key(&mut self, key: KeyCode, game: &Game, reg: &Registries) -> Response { 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::Char('q') | KeyCode::Esc => Response::Quit, _ => 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 { KeyCode::Esc | KeyCode::Char('q') => Response::Pop, _ => Response::None,