feat(charakterbau): Rasse und Klasse werden gewaehlt

Der Held war seit M1 als Zwergenkrieger verdrahtet, waehrend zwei Rassen
und zwei Klassen ungenutzt in der Registry lagen. Der Charakterbau ist
eine Schleife ueber categories_ordered() und options_of() — ein Mod, der
"Talent" mit pick = 2 ergaenzt, bekommt eine dritte Spalte ohne Code.

- CharacterOption bekommt abilities und on_create (Ticket 02, TODO seit M3)
- abilities_of liest jetzt Rasse und Klasse: Krieger traegt Wuchtschlag,
  Dieb Hinterhalt, Zwerg Steinsinn
- on_create gibt die Startausruestung als gewoehnliche Aktionen —
  starting_hero() schrumpft auf Creature::new(name)
- MainMenu trennt "neuer Held" von "Lauf fortsetzen"

Befund: Das Feld abilities fehlte, obwohl Ticket 15 sein ganzes Argument
darauf stuetzt — eine Klasse konnte nur flache Modifikatoren geben.
This commit is contained in:
2026-08-13 08:03:19 +02:00
parent b721947a65
commit 69efa72d68
10 changed files with 403 additions and 23 deletions

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,