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:
2026-08-12 22:01:12 +02:00
parent 7fdd8bbb00
commit d09fef7069
15 changed files with 698 additions and 64 deletions

View File

@@ -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;