feat(save): Speicherstand als Game-Baum
Autosave beim Betreten einer Etage und beim Beenden, ein Slot, bei Tod geloescht. Der Save ist der Baum selbst — keine eigenen Save-Structs, die man zweimal pflegt. - Kacheln als ASCII-Zeilen wie ein Prefab, nicht 3200 Enum-Eintraege - atomar geschrieben: tmp plus rename, der einzige Stand ueberlebt einen Abbruch - fremdes Schema wird abgelehnt, die Datei bleibt liegen - fehlende Mods verwerfen Eintraege, nie den Lauf - Confirm-Screen vor dem Spiel, wenn der Save Eintraege verloren hat Fuenf Befunde: die Ereigniswarteschlange gehoert nicht in den Save, die Feldreihenfolge ist Teil des TOML-Formats, Serialize fuer Floor muss von Hand, Confirm braucht on_no, und die Rueckfrage galt zuerst auch fuer Mod-Ladewarnungen.
This commit is contained in:
@@ -60,8 +60,7 @@ struct Manifest {
|
||||
id: String,
|
||||
#[allow(dead_code)]
|
||||
name: String,
|
||||
/// Einziger Leser ist der Save-Header (Ticket 03/09).
|
||||
#[allow(dead_code)]
|
||||
/// Einziger Leser ist der Save-Kopf (Ticket 03/09).
|
||||
version: String,
|
||||
#[serde(default)]
|
||||
depends: Vec<String>,
|
||||
@@ -70,6 +69,9 @@ struct Manifest {
|
||||
#[derive(Debug)]
|
||||
struct ModInfo {
|
||||
id: String,
|
||||
/// Nur fuer den Save-Kopf (Ticket 09) — wird verglichen und protokolliert,
|
||||
/// nie geprueft.
|
||||
version: String,
|
||||
depends: Vec<String>,
|
||||
path: PathBuf,
|
||||
}
|
||||
@@ -133,7 +135,7 @@ fn discover(warnings: &mut Vec<String>) -> Vec<ModInfo> {
|
||||
}
|
||||
found.insert(
|
||||
m.id.clone(),
|
||||
ModInfo { id: m.id, depends: m.depends, path },
|
||||
ModInfo { id: m.id, version: m.version, depends: m.depends, path },
|
||||
);
|
||||
}
|
||||
Err(e) => warnings.push(format!("{}: {}", manifest_path.display(), e.message())),
|
||||
@@ -531,6 +533,12 @@ pub fn load_mods() -> Result<(Registries, Vec<String>), LoadError> {
|
||||
Err(e) => warnings.push(format!("[balance]: {} — Standardwerte gelten", e.message())),
|
||||
}
|
||||
|
||||
reg.mods = order
|
||||
.iter()
|
||||
.filter_map(|id| found.iter().find(|m| &m.id == id))
|
||||
.map(|m| super::save::ModStamp { id: m.id.clone(), version: m.version.clone() })
|
||||
.collect();
|
||||
|
||||
// Phase 3 — Referenzen aufloesen
|
||||
resolve(&mut reg, &mut warnings);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
pub mod loader;
|
||||
pub mod registries;
|
||||
pub mod save;
|
||||
|
||||
pub use loader::{load_mods, write_log};
|
||||
pub use registries::{
|
||||
|
||||
@@ -477,6 +477,11 @@ pub struct Registries {
|
||||
pub npcs: HashMap<String, Npc>,
|
||||
/// Keine Registry — ein Block mit festem Schema, feldweise verschmolzen.
|
||||
pub balance: Balance,
|
||||
/// Wer geladen wurde, in Ladereihenfolge. **Einziger Leser ist der
|
||||
/// Save-Kopf** (Ticket 03/09): Er stempelt die Versionen, damit ein
|
||||
/// spaeterer Ladelauf „`meinmod` 1.0.0 → 1.2.0 seit dem Speichern" melden
|
||||
/// kann. Das ist die ganze Bilanz des `version`-Felds.
|
||||
pub mods: Vec<crate::data::save::ModStamp>,
|
||||
// TODO(M5+): `quests` (Ticket 08) und `scripts` (Ticket 13).
|
||||
}
|
||||
|
||||
|
||||
383
src/data/save.rs
Normal file
383
src/data/save.rs
Normal file
@@ -0,0 +1,383 @@
|
||||
//! Speicherstand — Ticket 09.
|
||||
//!
|
||||
//! **Der Save ist der `Game`-Baum.** Eigene Save-Structs hiessen, jede Struktur
|
||||
//! zweimal zu pflegen und bei jeder Aenderung daran zu denken — die Sorte
|
||||
//! Duplikat, die genau dann auseinanderlaeuft, wenn es niemand merkt. Der Preis
|
||||
//! ist ehrlich: Der Save haengt an internen Feldnamen, ein Umbenennen bricht
|
||||
//! alte Staende. Dafuer gibt es `schema`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::game::{Creature, Game, ItemInstance};
|
||||
|
||||
use super::Registries;
|
||||
|
||||
/// Steigt, wenn sich die **Struktur** aendert. Ein abweichender Wert wird
|
||||
/// abgelehnt, nicht migriert: Migration zu schreiben, bevor ein einziger
|
||||
/// Speicherstand ausgeliefert wurde, ist Code fuer ein Problem, das noch
|
||||
/// niemand hat.
|
||||
pub const SCHEMA: u32 = 1;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Header {
|
||||
pub schema: u32,
|
||||
/// Reine Diagnose bei Fehlermeldungen.
|
||||
pub game: String,
|
||||
#[serde(default)]
|
||||
pub mods: Vec<ModStamp>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModStamp {
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
/// Zwei Typen statt eines: Geschrieben wird aus Referenzen, gelesen in Besitz.
|
||||
/// Ein gemeinsamer Typ braeuchte entweder `Clone` auf dem ganzen `Game` — also
|
||||
/// eine Kopie der Etage bei jedem Autosave — oder einen Zeigertrick.
|
||||
#[derive(Serialize)]
|
||||
struct SaveOut<'a> {
|
||||
save: &'a Header,
|
||||
game: &'a Game,
|
||||
}
|
||||
|
||||
/// Ohne `save`-Feld: Der Kopf wird vorab einzeln gelesen, und unbekannte
|
||||
/// Schluessel ignoriert serde ohnehin.
|
||||
#[derive(Deserialize)]
|
||||
struct SaveIn {
|
||||
game: Game,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SaveError {
|
||||
/// Kein Speicherstand da — der Normalfall beim ersten Start.
|
||||
Missing,
|
||||
/// Falsches Schema. Die **Datei bleibt liegen**: Sie zu loeschen, weil man
|
||||
/// sie nicht lesen kann, waere die schlechteste aller Reaktionen.
|
||||
Schema { found: u32 },
|
||||
Unreadable(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SaveError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SaveError::Missing => write!(f, "Kein Speicherstand vorhanden"),
|
||||
SaveError::Schema { found } => write!(
|
||||
f,
|
||||
"Speicherstand stammt aus Schema {found}, dieses Spiel liest {SCHEMA}"
|
||||
),
|
||||
SaveError::Unreadable(why) => write!(f, "Speicherstand nicht lesbar: {why}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn path() -> Option<PathBuf> {
|
||||
// Dasselbe Verzeichnis wie Mods und `load_order.toml` — Ticket 03 hat die
|
||||
// Aufteilung in Daten und Konfiguration verworfen.
|
||||
super::loader::profile_dir().map(|d| d.join("save.toml"))
|
||||
}
|
||||
|
||||
/// Schreibt **atomar**: erst `save.toml.tmp`, dann `rename` darueber. Das ist
|
||||
/// die eine Stelle in Ticket 09, an der bewusst nicht lazy entschieden wurde —
|
||||
/// der Autosave ueberschreibt den einzigen Speicherstand, den es gibt. Bricht
|
||||
/// das Schreiben ab, liegt entweder die alte Datei da oder die neue, nie eine
|
||||
/// halbe.
|
||||
pub fn save(game: &Game, reg: &Registries) -> std::io::Result<()> {
|
||||
debug_assert!(
|
||||
game.events.is_empty(),
|
||||
"gespeichert wird zwischen Zuegen — die Warteschlange muss leer sein"
|
||||
);
|
||||
|
||||
let Some(ziel) = path() else {
|
||||
return Err(std::io::Error::other("kein Datenverzeichnis"));
|
||||
};
|
||||
if let Some(dir) = ziel.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
|
||||
let kopf = Header {
|
||||
schema: SCHEMA,
|
||||
game: env!("CARGO_PKG_VERSION").to_string(),
|
||||
mods: reg.mods.clone(),
|
||||
};
|
||||
let text = toml::to_string_pretty(&SaveOut { save: &kopf, game })
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
write_atomic(&ziel, &text)
|
||||
}
|
||||
|
||||
/// Erst daneben schreiben, dann darueberziehen. `rename` ersetzt die Zieldatei
|
||||
/// auf Windows 10 1607+ ebenso wie auf Unix.
|
||||
fn write_atomic(ziel: &std::path::Path, text: &str) -> std::io::Result<()> {
|
||||
let tmp = ziel.with_extension("toml.tmp");
|
||||
std::fs::write(&tmp, text)?;
|
||||
std::fs::rename(&tmp, ziel)
|
||||
}
|
||||
|
||||
pub fn load(reg: &Registries) -> Result<(Game, Vec<String>), SaveError> {
|
||||
let Some(quelle) = path() else { return Err(SaveError::Missing) };
|
||||
let Ok(text) = std::fs::read_to_string(&quelle) else {
|
||||
return Err(SaveError::Missing);
|
||||
};
|
||||
|
||||
// Erst nur den Kopf lesen: Bei falschem Schema ist der Rumpf strukturell
|
||||
// anders, und Best-Effort darauf erzeugt stille Verfaelschung.
|
||||
#[derive(Deserialize)]
|
||||
struct NurKopf {
|
||||
save: Header,
|
||||
}
|
||||
let kopf: NurKopf = toml::from_str(&text).map_err(|e| SaveError::Unreadable(e.to_string()))?;
|
||||
if kopf.save.schema != SCHEMA {
|
||||
return Err(SaveError::Schema { found: kopf.save.schema });
|
||||
}
|
||||
|
||||
let save: SaveIn = toml::from_str(&text).map_err(|e| SaveError::Unreadable(e.to_string()))?;
|
||||
let mut warnings = version_drift(&kopf.save.mods, reg);
|
||||
let mut game = save.game;
|
||||
prune(&mut game, reg, &mut warnings);
|
||||
Ok((game, warnings))
|
||||
}
|
||||
|
||||
pub fn delete() {
|
||||
if let Some(p) = path() {
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reine Diagnose — eine Zeile, die genau dann Gold wert ist, wenn jemand
|
||||
/// meldet „mein Spielstand ist seit dem Update kaputt". Geaenderte Werte gelten
|
||||
/// einfach, verschwundene IDs faengt `prune` ab.
|
||||
fn version_drift(damals: &[ModStamp], reg: &Registries) -> Vec<String> {
|
||||
let jetzt: HashMap<&str, &str> =
|
||||
reg.mods.iter().map(|m| (m.id.as_str(), m.version.as_str())).collect();
|
||||
damals
|
||||
.iter()
|
||||
.filter_map(|alt| match jetzt.get(alt.id.as_str()) {
|
||||
Some(neu) if *neu != alt.version => {
|
||||
Some(format!("`{}` {} → {neu} seit dem Speichern", alt.id, alt.version))
|
||||
}
|
||||
Some(_) => None,
|
||||
None => Some(format!("`{}` {} fehlt jetzt", alt.id, alt.version)),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// **Verwerfen, nie ablehnen** (Ticket 09): Ein Spieler soll nicht seinen
|
||||
/// ganzen Lauf verlieren, weil er einen Mod deinstalliert hat.
|
||||
fn prune(game: &mut Game, reg: &Registries, warnings: &mut Vec<String>) {
|
||||
prune_creature(&mut game.player, reg, warnings);
|
||||
for e in &mut game.enemies {
|
||||
prune_creature(e, reg, warnings);
|
||||
}
|
||||
for room in &mut game.floor.rooms {
|
||||
for e in &mut room.enemies {
|
||||
prune_creature(e, reg, warnings);
|
||||
}
|
||||
for npc in &mut room.npcs {
|
||||
prune_items(&mut npc.stock, reg, warnings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prune_creature(c: &mut Creature, reg: &Registries, warnings: &mut Vec<String>) {
|
||||
prune_items(&mut c.inventory, reg, warnings);
|
||||
|
||||
// Ausruestung: Der Slot bleibt leer, der Held bleibt spielbar.
|
||||
c.equipment.retain(|slot, inst| {
|
||||
let da = reg.items.contains_key(&inst.def) && reg.slots.contains_key(slot);
|
||||
if !da {
|
||||
warnings.push(format!("Ausrüstung `{}` fehlt, Slot bleibt leer", inst.def));
|
||||
}
|
||||
da
|
||||
});
|
||||
for inst in c.equipment.values_mut() {
|
||||
prune_affixes(inst, reg, warnings);
|
||||
}
|
||||
|
||||
c.options.retain(|id| {
|
||||
let da = reg.options.contains_key(id);
|
||||
if !da {
|
||||
warnings.push(format!("Charakteroption `{id}` fehlt"));
|
||||
}
|
||||
da
|
||||
});
|
||||
|
||||
// Ein Modifikator, dessen Definition fehlt, laeuft trotzdem ab — er traegt
|
||||
// seine Zahlen selbst (Ticket 07). Nichts zu tun.
|
||||
}
|
||||
|
||||
fn prune_items(v: &mut Vec<ItemInstance>, reg: &Registries, warnings: &mut Vec<String>) {
|
||||
v.retain(|inst| {
|
||||
let da = reg.items.contains_key(&inst.def);
|
||||
if !da {
|
||||
warnings.push(format!("Gegenstand `{}` fehlt", inst.def));
|
||||
}
|
||||
da
|
||||
});
|
||||
for inst in v.iter_mut() {
|
||||
prune_affixes(inst, reg, warnings);
|
||||
}
|
||||
}
|
||||
|
||||
fn prune_affixes(inst: &mut ItemInstance, reg: &Registries, warnings: &mut Vec<String>) {
|
||||
inst.affixes.retain(|a| {
|
||||
let da = reg.affixes.contains_key(a);
|
||||
if !da {
|
||||
warnings.push(format!("Affix `{a}` fehlt, Gegenstand bleibt"));
|
||||
}
|
||||
da
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::data::load_mods;
|
||||
use crate::game::{Attribute, CreatureRef, Rng};
|
||||
|
||||
fn lauf() -> (Game, Registries) {
|
||||
let (reg, _) = load_mods().expect("core muss laden");
|
||||
let mut held = Creature::new("Testheld");
|
||||
held.options = vec![String::from("core:dwarf")];
|
||||
held.equipment
|
||||
.insert(String::from("core:hand"), ItemInstance::new("core:iron_sword"));
|
||||
let game = Game::new(held, ®, Rng::new(4711)).expect("Etage 1");
|
||||
(game, reg)
|
||||
}
|
||||
|
||||
fn durch_toml(game: &Game, reg: &Registries) -> (String, Game) {
|
||||
let kopf = Header {
|
||||
schema: SCHEMA,
|
||||
game: String::from("test"),
|
||||
mods: reg.mods.clone(),
|
||||
};
|
||||
let text = toml::to_string_pretty(&SaveOut { save: &kopf, game })
|
||||
.expect("TOML verlangt Werte vor Tabellen — die Feldreihenfolge muss stimmen");
|
||||
let zurueck: SaveIn = toml::from_str(&text).expect("und wieder einlesbar");
|
||||
(text, zurueck.game)
|
||||
}
|
||||
|
||||
/// Der Save **ist** der Baum: Was rausgeht, muss unveraendert zurueckkommen.
|
||||
/// Faellt sofort um, wenn jemand ein Tabellenfeld vor ein Wertfeld schiebt.
|
||||
#[test]
|
||||
fn ein_lauf_ueberlebt_die_runde_durch_toml() {
|
||||
let (mut game, reg) = lauf();
|
||||
game.turn = 42;
|
||||
game.current_exp = 17;
|
||||
game.unspent_points = 2;
|
||||
game.give(ItemInstance::stack("core:gold", 130), ®);
|
||||
game.player.health = 33.0;
|
||||
|
||||
let (text, zurueck) = durch_toml(&game, ®);
|
||||
|
||||
assert_eq!(zurueck.turn, 42);
|
||||
assert_eq!(zurueck.current_exp, 17);
|
||||
assert_eq!(zurueck.unspent_points, 2);
|
||||
assert_eq!(zurueck.player.health, 33.0);
|
||||
assert_eq!(zurueck.player_pos, game.player_pos);
|
||||
assert_eq!(zurueck.gold(), 130, "der Stapel kommt als Stapel zurueck");
|
||||
assert_eq!(
|
||||
zurueck.player.equipment.get("core:hand").map(|i| i.def.as_str()),
|
||||
Some("core:iron_sword")
|
||||
);
|
||||
assert_eq!(
|
||||
zurueck.attribute(CreatureRef::Player, Attribute::Level, None, ®),
|
||||
game.attribute(CreatureRef::Player, Attribute::Level, None, ®)
|
||||
);
|
||||
|
||||
// Die Etage kommt Kachel fuer Kachel zurueck — und steht als lesbare
|
||||
// Zeilen in der Datei, nicht als 3200 Enum-Eintraege (Ticket 09).
|
||||
assert_eq!(zurueck.floor.rows(), game.floor.rows());
|
||||
assert_eq!(zurueck.floor.rooms.len(), game.floor.rooms.len());
|
||||
assert!(text.contains("####"), "die Kacheln muessen als Zeilen dastehen");
|
||||
assert!(!text.contains("\"wall\""), "und nicht als Enum-Namen");
|
||||
}
|
||||
|
||||
/// **Verwerfen, nie ablehnen**: Ein fehlender Mod kostet Eintraege, nicht
|
||||
/// den Lauf.
|
||||
#[test]
|
||||
fn fehlende_eintraege_werden_verworfen_nicht_abgelehnt() {
|
||||
let (mut game, reg) = lauf();
|
||||
game.player.inventory.push(ItemInstance::new("weg:phantom"));
|
||||
game.player.options.push(String::from("weg:klasse"));
|
||||
let mut verhext = ItemInstance::new("core:iron_sword");
|
||||
verhext.affixes = vec![String::from("weg:affix"), String::from("core:sharp")];
|
||||
game.player.inventory.push(verhext);
|
||||
game.player
|
||||
.equipment
|
||||
.insert(String::from("core:head"), ItemInstance::new("weg:helm"));
|
||||
|
||||
let mut warnungen = Vec::new();
|
||||
prune(&mut game, ®, &mut warnungen);
|
||||
|
||||
assert!(!game.player.inventory.iter().any(|i| i.def == "weg:phantom"));
|
||||
assert!(!game.player.options.iter().any(|o| o == "weg:klasse"));
|
||||
assert!(!game.player.equipment.contains_key("core:head"), "Slot bleibt leer");
|
||||
|
||||
let schwert = game
|
||||
.player
|
||||
.inventory
|
||||
.iter()
|
||||
.find(|i| i.def == "core:iron_sword")
|
||||
.expect("der Gegenstand bleibt, nur das Affix faellt");
|
||||
assert_eq!(schwert.affixes, vec![String::from("core:sharp")]);
|
||||
|
||||
assert_eq!(warnungen.len(), 4, "jeder Verlust bekommt eine Zeile: {warnungen:?}");
|
||||
}
|
||||
|
||||
/// Falsches Schema: ablehnen mit Klartext, **Datei bleibt liegen**.
|
||||
#[test]
|
||||
fn fremdes_schema_wird_abgelehnt() {
|
||||
let (game, _reg) = lauf();
|
||||
let kopf = Header { schema: SCHEMA + 1, game: String::from("test"), mods: Vec::new() };
|
||||
let text = toml::to_string_pretty(&SaveOut { save: &kopf, game: &game }).expect("schreibt");
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NurKopf {
|
||||
save: Header,
|
||||
}
|
||||
let gelesen: NurKopf = toml::from_str(&text).expect("der Kopf ist immer lesbar");
|
||||
assert_ne!(gelesen.save.schema, SCHEMA);
|
||||
let fehler = SaveError::Schema { found: gelesen.save.schema };
|
||||
assert!(fehler.to_string().contains("Schema 2"), "{fehler}");
|
||||
}
|
||||
|
||||
/// Der Versionsvergleich leistet **reine Diagnose** — die Zeile, die Gold
|
||||
/// wert ist, wenn jemand meldet „seit dem Update kaputt".
|
||||
#[test]
|
||||
fn mod_versionen_werden_nur_protokolliert() {
|
||||
let (_, reg) = lauf();
|
||||
let damals = vec![
|
||||
ModStamp { id: String::from("core"), version: String::from("0.0.1") },
|
||||
ModStamp { id: String::from("weg"), version: String::from("1.0.0") },
|
||||
];
|
||||
let zeilen = version_drift(&damals, ®);
|
||||
assert_eq!(zeilen.len(), 2, "{zeilen:?}");
|
||||
assert!(zeilen[0].contains("core"));
|
||||
assert!(zeilen[1].contains("fehlt jetzt"));
|
||||
}
|
||||
|
||||
/// Die eine Stelle, an der Ticket 09 bewusst nicht lazy entschieden hat:
|
||||
/// Der Autosave ueberschreibt den **einzigen** Speicherstand. Geprueft wird
|
||||
/// das Beobachtbare — die alte Datei wird ersetzt, und es bleibt kein
|
||||
/// halber Rest liegen.
|
||||
#[test]
|
||||
fn atomar_geschrieben_bleibt_kein_rest_liegen() {
|
||||
let dir = std::env::temp_dir().join(format!("rustkeep-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("Testverzeichnis");
|
||||
let ziel = dir.join("save.toml");
|
||||
|
||||
write_atomic(&ziel, "alt").expect("erstes Schreiben");
|
||||
assert_eq!(std::fs::read_to_string(&ziel).unwrap(), "alt");
|
||||
|
||||
write_atomic(&ziel, "neu").expect("darueberziehen");
|
||||
assert_eq!(std::fs::read_to_string(&ziel).unwrap(), "neu", "rename ersetzt");
|
||||
assert!(!ziel.with_extension("toml.tmp").exists(), "keine halbe Datei daneben");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,14 @@
|
||||
//! vorbei. Getrennte Maschine vom Modifikator, der nie ausgefuehrt, sondern beim
|
||||
//! Auslesen aufsummiert wird.
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::attributes::Attribute;
|
||||
|
||||
/// Sieben feste Kennzeichnungen, wie Schaden gemindert wird. Engine-Vokabular
|
||||
/// ohne Namensraum. Zu jedem Typ gehoert eine Resistenz, und Resistenz ist der
|
||||
/// *einzige* Minderungsmechanismus (Ticket 01).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[repr(usize)]
|
||||
pub enum DamageType {
|
||||
@@ -121,7 +121,7 @@ pub enum Action {
|
||||
|
||||
/// Die beiden Zustandsgroessen, die eine Aktion aendern darf. Bewusst **kein**
|
||||
/// [`Attribute`]: Attribute werden gerechnet, Zustand wird gesetzt.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StateField {
|
||||
Health,
|
||||
@@ -142,11 +142,12 @@ impl Action {
|
||||
|
||||
/// Ein befristeter Modifikator, der auf einem Wesen liegt. Er **wohnt beim
|
||||
/// Traeger** (Ticket 01) — stirbt der Traeger, verschwindet die Aura von selbst.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ActiveModifier {
|
||||
/// ID des Registry-Eintrags, damit `remove_modifier` ihn findet.
|
||||
pub id: String,
|
||||
pub attribute: Attribute,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub damage_type: Option<DamageType>,
|
||||
pub flat: f32,
|
||||
pub percent: f32,
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
//! Der Katalog ist **fest** und Engine-Vokabular ohne Namensraum. Ein Modder
|
||||
//! kann ihn nicht erweitern; das ist Absicht (siehe Glossar, „Attribut").
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Fester Katalog aus Ticket 01. `level` steht hier, weil Bedingungen und
|
||||
/// Formeln es lesen — die Quelle fuer Aufstiegspunkte ist aber `base.level`
|
||||
/// und nicht dieser gelesene Wert (Ticket 15).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[repr(usize)]
|
||||
pub enum Attribute {
|
||||
@@ -88,7 +88,7 @@ impl Attribute {
|
||||
/// Die Grundwerte eines Wesens. Bewusst ein Array statt vierzehn benannter
|
||||
/// Felder: `get` und `set` sind damit je eine Zeile statt je eines
|
||||
/// vierzehnarmigen `match`.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Attributes([f32; Attribute::COUNT]);
|
||||
|
||||
impl Attributes {
|
||||
|
||||
@@ -56,40 +56,42 @@ pub enum CreatureRef {
|
||||
Enemy(usize),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Creature {
|
||||
pub name: String,
|
||||
/// Grundwerte. `base.level` ist die einzige Quelle fuer Aufstiegspunkte
|
||||
/// (Ticket 15) — das *gelesene* Attribut darf hoeher ausfallen.
|
||||
pub base: Attributes,
|
||||
/// Zustand, scharf getrennt von den Attributen (Ticket 01).
|
||||
pub health: f32,
|
||||
pub stamina: f32,
|
||||
/// Einmal beim Betreten des Kampfes gewuerfelt (Ticket 14).
|
||||
pub initiative: i32,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
/// Aus welcher `creatures`-Definition dieses Wesen stammt — der Held hat
|
||||
/// keine. Traegt `ai`-Liste, Faehigkeiten und Trigger.
|
||||
pub def: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
/// **Die Ansage**: Was dieses Wesen im naechsten Zug tun wird — verbindlich
|
||||
/// und deshalb stoerbar.
|
||||
pub intent: Option<String>,
|
||||
/// Grundwerte. `base.level` ist die einzige Quelle fuer Aufstiegspunkte
|
||||
/// (Ticket 15) — das *gelesene* Attribut darf hoeher ausfallen.
|
||||
pub base: Attributes,
|
||||
/// Gewaehlte Charakteroptionen. Ohne dieses Feld haette der Held nach dem
|
||||
/// Charakterbau keine Rasse mehr (Ticket 07).
|
||||
pub options: Vec<String>,
|
||||
pub inventory: Vec<ItemInstance>,
|
||||
pub equipment: HashMap<String, ItemInstance>,
|
||||
pub tags: Vec<String>,
|
||||
/// Faehigkeiten, die *nicht* aus Optionen oder Ausruestung kommen.
|
||||
pub abilities: Vec<String>,
|
||||
|
||||
// Ab hier stehen TOML-**Tabellen**. Die Reihenfolge ist keine Kosmetik:
|
||||
// TOML verlangt Werte vor Tabellen, und der Speicherstand ist der Baum
|
||||
// selbst (Ticket 09).
|
||||
pub inventory: Vec<ItemInstance>,
|
||||
/// **Nur befristete** Modifikatoren. Rasse und Klasse kommen aus `options`,
|
||||
/// Ausruestung aus `equipment` — hineinzukopieren machte das Lesen billiger
|
||||
/// und das Schreiben gefaehrlich (Ticket 07).
|
||||
pub modifiers: Vec<ActiveModifier>,
|
||||
/// Einmal beim Betreten des Kampfes gewuerfelt. Bewusst ein Zahlenwert am
|
||||
/// Wesen und keine Reihenfolgeliste: Die waere eine **zweite** Indexliste
|
||||
/// mit demselben Ungueltigkeitsproblem beim Tod eines Gegners (Ticket 14).
|
||||
pub initiative: i32,
|
||||
pub equipment: HashMap<String, ItemInstance>,
|
||||
pub cooldowns: HashMap<String, u32>,
|
||||
/// **Die Ansage**: Was dieses Wesen im naechsten Zug tun wird. Am Zugende
|
||||
/// festgelegt und damit verbindlich — nur so ist sie *stoerbar*. Eine
|
||||
/// Vorschau, die sich mit der Lage aendert, waere keine Information,
|
||||
/// sondern eine Vermutung, und darauf laesst sich keine Taktik bauen.
|
||||
pub intent: Option<String>,
|
||||
/// Aus welcher `creatures`-Definition dieses Wesen stammt — der Held hat
|
||||
/// keine. Traegt `ai`-Liste, Faehigkeiten und Trigger.
|
||||
pub def: Option<String>,
|
||||
/// Faehigkeiten, die *nicht* aus Optionen oder Ausruestung kommen.
|
||||
pub abilities: Vec<String>,
|
||||
}
|
||||
|
||||
impl Creature {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! wirkt einmal, Faehigkeit bietet an — der Trigger sagt *wann*. Er darf
|
||||
//! ueberall stehen, wo `modifiers` und `actions` stehen.
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::action::{Action, DamageType};
|
||||
use super::condition::Condition;
|
||||
@@ -16,7 +16,7 @@ use super::creature::CreatureRef;
|
||||
/// `attack_hit` und `damage_taken` bleiben getrennt: Ersteres feuert beim
|
||||
/// Angreifer (dort haengt das flammende Schwert), Letzteres beim Getroffenen
|
||||
/// (dort haengt der Dornenpanzer).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EventKind {
|
||||
TurnStarted,
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
//! hiesse, die Prefabs zweimal zu parsen.
|
||||
#![allow(dead_code)]
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Tile {
|
||||
Wall,
|
||||
Floor,
|
||||
@@ -15,7 +18,7 @@ pub enum Tile {
|
||||
Entrance,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Rect {
|
||||
pub x: u16,
|
||||
pub y: u16,
|
||||
@@ -41,50 +44,112 @@ impl Rect {
|
||||
/// Ein NPC auf der Etage. Sein Warenkorb wird **einmal bei der Erzeugung**
|
||||
/// gezogen und liegt danach fest — sonst wuerfelte jedes Oeffnen des Ladens neu
|
||||
/// und der Spieler koennte sich das Angebot herbeischliessen (M4).
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct NpcInstance {
|
||||
pub pos: (u16, u16),
|
||||
pub def: String,
|
||||
/// Tabelle, also zuletzt (TOML).
|
||||
pub stock: Vec<super::creature::ItemInstance>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Room {
|
||||
/// Fuer `room_entered` und die Trigger am Prefab (Ticket 08/10).
|
||||
pub prefab: String,
|
||||
pub rect: Rect,
|
||||
/// Aus welcher Tabelle die Haufen dieses Raums ziehen — Prefab-`loot` oder,
|
||||
/// wenn es fehlt, die Etagentabelle (Ticket 16).
|
||||
pub loot_table: String,
|
||||
pub cleared: bool,
|
||||
/// Die `e`-Plaetze aus dem Prefab — bleiben erhalten, damit die Anzeige
|
||||
/// weiss, wo etwas stand.
|
||||
pub enemy_spawns: Vec<(u16, u16)>,
|
||||
/// Die Gegner dieses Raums. Wandern beim Betreten nach `Game.enemies` —
|
||||
/// **verschoben, nicht kopiert** (Ticket 10).
|
||||
pub enemies: Vec<super::creature::Creature>,
|
||||
/// Aus welcher Tabelle die Haufen dieses Raums ziehen — Prefab-`loot` oder,
|
||||
/// wenn es fehlt, die Etagentabelle. **Eine** ID je Raum: Alle Haufen ziehen
|
||||
/// ohnehin aus derselben, sie je Haufen zu wiederholen waere Redundanz
|
||||
/// (Ticket 16).
|
||||
pub loot_table: String,
|
||||
/// Die `l`-Plaetze. Ticket 16: erst beim **Aufheben** aufgeloest, deshalb
|
||||
/// stehen hier Positionen und keine Gegenstaende.
|
||||
pub loot: Vec<(u16, u16)>,
|
||||
|
||||
// Tabellen zuletzt (TOML).
|
||||
pub rect: Rect,
|
||||
/// Die Gegner dieses Raums. Wandern beim Betreten nach `Game.enemies` —
|
||||
/// **verschoben, nicht kopiert** (Ticket 10).
|
||||
pub enemies: Vec<super::creature::Creature>,
|
||||
/// Die `n`-Plaetze, schon aufgeloest. Anders als bei `e` nennt das Prefab
|
||||
/// den NPC — ein Schmied skaliert nicht mit der Tiefe.
|
||||
pub npcs: Vec<NpcInstance>,
|
||||
pub cleared: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Floor {
|
||||
pub number: u8,
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
/// **Die eine handgeschriebene Ausnahme** (Ticket 09): Im Speicherstand
|
||||
/// stehen ASCII-Zeilen wie in einem Prefab, nicht 3200 Enum-Eintraege. Rund
|
||||
/// 3 KB, lesbar, im Notfall von Hand reparierbar.
|
||||
#[serde(deserialize_with = "rows_to_tiles")]
|
||||
pub tiles: Vec<Tile>,
|
||||
pub rooms: Vec<Room>,
|
||||
/// `None` ist ein echter Zustand: Korridore gehoeren zu keinem Raum.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub player_room: Option<usize>,
|
||||
pub rooms: Vec<Room>,
|
||||
}
|
||||
|
||||
impl Tile {
|
||||
fn symbol(self) -> char {
|
||||
match self {
|
||||
Tile::Wall => '#',
|
||||
Tile::Floor => '.',
|
||||
Tile::Door => '+',
|
||||
Tile::Stairs => '>',
|
||||
Tile::Entrance => '<',
|
||||
}
|
||||
}
|
||||
|
||||
/// Unbekannte Zeichen werden zur Wand. Ein handverbogener Speicherstand
|
||||
/// soll den Helden einmauern, nicht das Spiel abbrechen.
|
||||
fn from_symbol(c: char) -> Tile {
|
||||
match c {
|
||||
'.' => Tile::Floor,
|
||||
'+' => Tile::Door,
|
||||
'>' => Tile::Stairs,
|
||||
'<' => Tile::Entrance,
|
||||
_ => Tile::Wall,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rows_to_tiles<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<Tile>, D::Error> {
|
||||
let rows = Vec::<String>::deserialize(d)?;
|
||||
Ok(rows.iter().flat_map(|r| r.chars()).map(Tile::from_symbol).collect())
|
||||
}
|
||||
|
||||
/// `Serialize` von Hand — und **nur** deshalb, weil der Zeilenumbruch die
|
||||
/// Breite braucht, die ein feldweises `serde(with)` nicht sieht. Der Rest ist
|
||||
/// eins zu eins der Baum; eigene Save-Structs hiessen, jede Struktur zweimal zu
|
||||
/// pflegen (Ticket 09).
|
||||
impl Serialize for Floor {
|
||||
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
use serde::ser::SerializeStruct;
|
||||
let mut st = s.serialize_struct("Floor", 6)?;
|
||||
st.serialize_field("number", &self.number)?;
|
||||
st.serialize_field("width", &self.width)?;
|
||||
st.serialize_field("height", &self.height)?;
|
||||
st.serialize_field("tiles", &self.rows())?;
|
||||
st.serialize_field("player_room", &self.player_room)?;
|
||||
// Tabellen zuletzt: TOML verlangt Werte vor Tabellen.
|
||||
st.serialize_field("rooms", &self.rooms)?;
|
||||
st.end()
|
||||
}
|
||||
}
|
||||
|
||||
impl Floor {
|
||||
/// Das Kachelgitter als Textzeilen — dasselbe Format wie ein Prefab.
|
||||
pub fn rows(&self) -> Vec<String> {
|
||||
self.tiles
|
||||
.chunks(self.width as usize)
|
||||
.map(|z| z.iter().map(|t| t.symbol()).collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn tile(&self, x: u16, y: u16) -> Tile {
|
||||
if x >= self.width || y >= self.height {
|
||||
return Tile::Wall;
|
||||
|
||||
@@ -25,41 +25,60 @@ use crate::data::Registries;
|
||||
|
||||
/// Wie ein Lauf endet (Ticket 10/14). Sieg ueber das **Fehlen** der naechsten
|
||||
/// Etage — es gibt keine Zahl „5" im Code.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RunOutcome {
|
||||
Died,
|
||||
Won,
|
||||
}
|
||||
|
||||
/// **Der Speicherstand ist dieser Baum** (Ticket 09) — keine eigenen
|
||||
/// Save-Structs, die man zweimal pflegt und die genau dann auseinanderlaufen,
|
||||
/// wenn es niemand merkt.
|
||||
///
|
||||
/// Die Feldreihenfolge ist deshalb keine Kosmetik: TOML verlangt Werte vor
|
||||
/// Tabellen. Alles Skalare steht oben, alles Verschachtelte unten.
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Game {
|
||||
pub player: Creature,
|
||||
/// Der **laufende Kampf**, nicht die Etage (Ticket 07/14). Leer = kein Kampf.
|
||||
pub enemies: Vec<Creature>,
|
||||
pub floor: Floor,
|
||||
pub player_pos: (u16, u16),
|
||||
pub turn: u64,
|
||||
/// Im Speicherstand, damit Nachladen nicht neu wuerfelt (Ticket 14).
|
||||
pub rng: Rng,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub over: Option<RunOutcome>,
|
||||
/// Warteschlange statt Rekursion (Ticket 08): Synchron zu feuern hiesse,
|
||||
/// ueber `self.enemies` zu iterieren, waehrend `self.apply` erneut laeuft.
|
||||
pub events: std::collections::VecDeque<Event>,
|
||||
/// Letzte Meldung fuer die Statuszeile.
|
||||
pub last: String,
|
||||
/// Kampflog, die letzten Zeilen.
|
||||
pub journal: Vec<String>,
|
||||
/// Mit wem gerade gesprochen wird — Index in `Room.npcs`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub talking_to: Option<usize>,
|
||||
/// Absicht aus `open_shop`. Die Schleife liest sie und schiebt den Screen;
|
||||
/// `game/` kennt keine Screens.
|
||||
#[serde(default)]
|
||||
pub open_shop: bool,
|
||||
/// Erfahrung und unverteilte Punkte stehen an `Game`, **nicht am
|
||||
/// `Creature`** (Ticket 15): Nur der Held hat beides, und ein Feld, das
|
||||
/// fuer 99 % der Exemplare bedeutungslos ist, waere ein Kommentar in
|
||||
/// Feldform — den der Speicherstand fuer jeden Gegner mitschriebe.
|
||||
#[serde(default)]
|
||||
pub current_exp: u32,
|
||||
#[serde(default)]
|
||||
pub unspent_points: u32,
|
||||
// TODO(M6): `quests` (Ticket 08).
|
||||
|
||||
/// Warteschlange statt Rekursion (Ticket 08). **Nicht im Speicherstand:**
|
||||
/// Sie ist Zwischenstand *innerhalb* eines Zuges — `drain_events` leert sie
|
||||
/// nach jeder Handlung, und gespeichert wird nur zwischen Zuegen. Sie
|
||||
/// mitzuschreiben hiesse, immer eine leere Liste zu speichern.
|
||||
#[serde(skip)]
|
||||
pub events: std::collections::VecDeque<Event>,
|
||||
|
||||
// Ab hier Tabellen.
|
||||
pub player: Creature,
|
||||
/// Der **laufende Kampf**, nicht die Etage (Ticket 07/14). Leer = kein Kampf.
|
||||
pub enemies: Vec<Creature>,
|
||||
pub floor: Floor,
|
||||
// TODO(M7): `quests` (Ticket 08).
|
||||
}
|
||||
|
||||
impl Game {
|
||||
|
||||
23
src/main.rs
23
src/main.rs
@@ -18,7 +18,7 @@ use game::{Creature, ItemInstance};
|
||||
fn main() {
|
||||
// Die `Result`-Kette endet **vor** `main` (Ticket 04): `main() -> Result<…>`
|
||||
// druckt ueber `Debug` und setzt dem Spieler `LoadError { .. }` vor.
|
||||
let (registries, warnings) = match data::load_mods() {
|
||||
let (registries, mut warnings) = match data::load_mods() {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("{e}");
|
||||
@@ -26,18 +26,35 @@ fn main() {
|
||||
}
|
||||
};
|
||||
|
||||
// Ein Speicherstand, kein Slot-Menue (Ticket 09). Liegt einer da, wird er
|
||||
// fortgesetzt; sonst faengt ein Lauf an. Bei Tod loescht die Schleife ihn,
|
||||
// also entscheidet allein sein Vorhandensein.
|
||||
let (geladen, verworfen) = match data::save::load(®istries) {
|
||||
Ok((game, aus_dem_save)) => (Some(game), aus_dem_save),
|
||||
Err(data::save::SaveError::Missing) => (None, Vec::new()),
|
||||
// Falsches Schema oder kaputte Datei: melden, **liegen lassen** und neu
|
||||
// anfangen. Loeschen waere die schlechteste aller Reaktionen.
|
||||
Err(e) => (None, vec![e.to_string()]),
|
||||
};
|
||||
// Getrennt gezaehlt: Mod-Ladewarnungen sind eine Zeile im Hauptmenue
|
||||
// (Ticket 04), nur Verworfenes aus dem Save fragt zurueck (Ticket 09).
|
||||
let rueckfragen = verworfen.len();
|
||||
warnings.extend(verworfen);
|
||||
|
||||
let log_name = data::write_log(&warnings);
|
||||
|
||||
// Der Seed wird genau einmal frisch gezogen. Ab hier steht er im Spiel und
|
||||
// spaeter im Speicherstand — Nachladen wuerfelt nicht neu (Ticket 14).
|
||||
let Some(game) = game::Game::new(starting_hero(), ®istries, game::Rng::from_clock()) else {
|
||||
let Some(game) =
|
||||
geladen.or_else(|| game::Game::new(starting_hero(), ®istries, game::Rng::from_clock()))
|
||||
else {
|
||||
eprintln!(
|
||||
"Keine Etage mit number = 1 gefunden. Erwartet wird ein [[floors]]-Eintrag in mods/core."
|
||||
);
|
||||
std::process::exit(1);
|
||||
};
|
||||
|
||||
if let Err(e) = ui::run(game, registries, warnings.len(), log_name) {
|
||||
if let Err(e) = ui::run(game, registries, warnings.len(), rueckfragen, log_name) {
|
||||
eprintln!("Terminal-Fehler: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use ratatui::widgets::{Block, Paragraph, Row, Table};
|
||||
use super::grid::GridWidget;
|
||||
use super::screen::{Response, Screen};
|
||||
use super::theme;
|
||||
use crate::data::Registries;
|
||||
use crate::data::{Registries, save};
|
||||
use crate::game::{Attribute, CreatureRef, DamageType, Game, Outcome, RunOutcome};
|
||||
|
||||
pub struct App {
|
||||
@@ -28,6 +28,17 @@ impl App {
|
||||
/// Der Spielzustand fuehrt, der Stack folgt: `Game.enemies` und `Game.over`
|
||||
/// sind die Wahrheit. Ohne das muesste jede Stelle, die einen Kampf startet
|
||||
/// oder beendet, daran denken, den Screen mitzuschieben.
|
||||
/// Beim Beenden und beim Betreten einer Etage. Ein gescheiterter Autosave
|
||||
/// bricht das Spiel nicht ab — er landet in der Statuszeile.
|
||||
fn autosave(&mut self) {
|
||||
if self.game.over.is_some() {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = save::save(&self.game, &self.registries) {
|
||||
self.game.last = format!("Speichern fehlgeschlagen: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_screens(&mut self) {
|
||||
let in_combat = !self.game.enemies.is_empty();
|
||||
let shows_combat = self.screens.iter().any(|s| matches!(s, Screen::Combat { .. }));
|
||||
@@ -63,11 +74,34 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(game: Game, registries: Registries, warnings: usize, log_name: Option<String>) -> std::io::Result<()> {
|
||||
pub fn run(
|
||||
game: Game,
|
||||
registries: Registries,
|
||||
warnings: usize,
|
||||
verworfen: usize,
|
||||
log_name: Option<String>,
|
||||
) -> std::io::Result<()> {
|
||||
let mut screens = vec![Screen::MainMenu];
|
||||
|
||||
// Die Rueckfrage steht **vor** dem Spiel, nicht als Zeile darin (Ticket
|
||||
// 09): Wer hier „nein" sagt, will den fehlenden Mod nachinstallieren — und
|
||||
// diese Wahl hat er nur jetzt, danach ist der verkrueppelte Held gespeichert.
|
||||
if verworfen > 0 {
|
||||
let wo = log_name.as_deref().unwrap_or("kein Log schreibbar");
|
||||
screens.push(Screen::Confirm {
|
||||
text: format!(
|
||||
"{verworfen} Einträge aus dem Speicherstand fehlen (siehe logs/{wo}).
|
||||
Fortfahren?"
|
||||
),
|
||||
on_yes: Box::new(Response::Pop),
|
||||
on_no: Box::new(Response::Quit),
|
||||
});
|
||||
}
|
||||
|
||||
let mut app = App {
|
||||
game,
|
||||
registries,
|
||||
screens: vec![Screen::MainMenu],
|
||||
screens,
|
||||
warnings,
|
||||
log_name,
|
||||
rejected: None,
|
||||
@@ -99,11 +133,13 @@ pub fn run(game: Game, registries: Registries, warnings: usize, log_name: Option
|
||||
}
|
||||
app.screens.pop();
|
||||
if app.screens.is_empty() {
|
||||
app.autosave();
|
||||
break Ok(());
|
||||
}
|
||||
}
|
||||
Response::Push(s) => app.screens.push(s),
|
||||
Response::Command(cmd) => {
|
||||
let etage_vorher = app.game.floor.number;
|
||||
// Hier steht die Zusage aus Ticket 07 auf dem Pruefstand:
|
||||
// `&mut app.game` neben `&app.registries` in einem Aufruf.
|
||||
app.rejected = match app.game.execute(cmd, &app.registries) {
|
||||
@@ -117,8 +153,22 @@ pub fn run(game: Game, registries: Registries, warnings: usize, log_name: Option
|
||||
// die UI — die reagiert nur darauf. Dasselbe gilt fuer den
|
||||
// Kampf: `Game.enemies` ist die Wahrheit, der Screen folgt.
|
||||
app.sync_screens();
|
||||
|
||||
// Autosave beim **Betreten einer Etage** (Ticket 09). Bei
|
||||
// Tod stattdessen loeschen — Permadeath, und zwar erst hier
|
||||
// beim `GameOver`-Screen: Stuerzt das Spiel in der Sekunde
|
||||
// davor ab, ist der letzte Autosave noch da, und der stammt
|
||||
// vom Betreten der Etage, verschenkt also nichts.
|
||||
if app.game.over.is_some() {
|
||||
save::delete();
|
||||
} else if app.game.floor.number != etage_vorher {
|
||||
app.autosave();
|
||||
}
|
||||
}
|
||||
Response::Quit => {
|
||||
app.autosave();
|
||||
break Ok(());
|
||||
}
|
||||
Response::Quit => break Ok(()),
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -141,6 +191,7 @@ 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::Confirm { text, .. } => draw_confirm(frame, area, text),
|
||||
Screen::Debug { pick } => draw_debug(frame, area, app, *pick),
|
||||
Screen::GameOver => draw_game_over(frame, area, app),
|
||||
}
|
||||
@@ -347,6 +398,17 @@ fn draw_shop(frame: &mut Frame, area: Rect, app: &App, pick: usize, selling: boo
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_confirm(frame: &mut Frame, area: Rect, text: &str) {
|
||||
let mut zeilen: Vec<Line> = text.lines().map(Line::from).collect();
|
||||
zeilen.push(Line::from(""));
|
||||
zeilen.push(Line::from("j / Enter fortfahren").style(Style::new().fg(Color::Yellow)));
|
||||
zeilen.push(Line::from("n / Esc beenden").style(Style::new().fg(Color::DarkGray)));
|
||||
frame.render_widget(
|
||||
Paragraph::new(zeilen).block(Block::bordered().title(" Rückfrage ")),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
/// Die Werkbank. Eine Liste, vier Tasten — was sich mit zwei vorhandenen
|
||||
/// Kommandos nachstellen laesst, steht hier nicht.
|
||||
fn draw_debug(frame: &mut Frame, area: Rect, app: &App, pick: usize) {
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::game::{Attribute, Cheat, Command, CreatureRef, Direction, Game};
|
||||
/// Popups sind gewoehnliche Screens, die sich beim Rendern als Overlay
|
||||
/// ausweisen — ein gemeinsamer `Vec<Screen>` statt `current` plus
|
||||
/// `Option<Popup>`, weil Popup ueber Popup real ist.
|
||||
#[derive(Clone)]
|
||||
pub enum Screen {
|
||||
MainMenu,
|
||||
Explore,
|
||||
@@ -38,6 +39,16 @@ pub enum Screen {
|
||||
/// Meldungszeile im Hauptmenue: Ein Abstiegs-Roguelike lebt davon, dass der
|
||||
/// Lauf am Ende *erzaehlt* wird (Ticket 14).
|
||||
GameOver,
|
||||
/// Rueckfrage. Ticket 09 wollte `on_yes: Box<Response>` und ein implizites
|
||||
/// „Nein = wegklicken" — das reicht nicht: Beim Ladefall heisst Nein
|
||||
/// *beenden und den Mod nachinstallieren*, denn diese Wahl hat der Spieler
|
||||
/// nur **vor** dem Laden. Das symmetrische Paar ist ein Feld mehr und
|
||||
/// spart die ungeschriebene Regel.
|
||||
Confirm {
|
||||
text: String,
|
||||
on_yes: Box<Response>,
|
||||
on_no: Box<Response>,
|
||||
},
|
||||
/// Werkbank. Immer mit gebaut statt hinter `cfg(debug_assertions)`: Das
|
||||
/// waere ein `#[cfg]` an der Variante *und* an jedem `match` darueber, fuer
|
||||
/// ein Spiel, das niemand ausliefert. Ein `#[cfg]` reicht spaeter nach.
|
||||
@@ -63,6 +74,7 @@ pub enum Screen {
|
||||
/// Der Screen **gibt zurueck, statt selbst zu schieben**: sonst haelt er ein
|
||||
/// `&mut` auf genau die `Vec`, in der er liegt. Nebeneffekt — `Response::Command`
|
||||
/// ist der einzige Weg nach `game/`.
|
||||
#[derive(Clone)]
|
||||
pub enum Response {
|
||||
None,
|
||||
Pop,
|
||||
@@ -344,6 +356,12 @@ impl Screen {
|
||||
}
|
||||
}
|
||||
|
||||
Screen::Confirm { on_yes, on_no, .. } => match key {
|
||||
KeyCode::Char('j' | 'y') | KeyCode::Enter => (**on_yes).clone(),
|
||||
KeyCode::Char('n' | 'q') | KeyCode::Esc => (**on_no).clone(),
|
||||
_ => Response::None,
|
||||
},
|
||||
|
||||
Screen::GameOver => Response::Quit,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user