Merge branch 'feat/m8-charakterbau'
This commit is contained in:
@@ -116,3 +116,28 @@ dessen leerer Liste die KI aus Ticket 14 sonst stünde.
|
||||
`src/items/weapon.rs` und `src/items/armor.rs` werden gelöscht, `enum ItemType` entfällt,
|
||||
`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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" } ]
|
||||
|
||||
@@ -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 } ]
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
22
src/main.rs
22
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")
|
||||
}
|
||||
|
||||
118
src/ui/app.rs
118
src/ui/app.rs
@@ -66,6 +66,14 @@ impl App {
|
||||
self.screens.push(Screen::Shop { pick: 0, selling: false });
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user