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,
`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.

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

View File

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

View File

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

View File

@@ -97,10 +97,19 @@ pub struct CharacterOption {
pub description: String,
#[serde(default)]
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)]
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".
@@ -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()));

View File

@@ -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<String>),
/// **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.

View File

@@ -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"), &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 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(), &registries, 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")
}

View File

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

View File

@@ -10,7 +10,11 @@ use crate::game::{Attribute, Cheat, Command, CreatureRef, Direction, Game};
/// `Option<Popup>`, 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<String>,
},
/// 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,