feat: m1 vertikaler durchstich — mod-lader, registries, attributberechnung, screen-stack
Ersetzt src/ vollstaendig: print_box, include_str! und enum ItemType fallen, wie Ticket 01-03 es vorgezeichnet haben. - data/: Mod-Lader mit zwei Suchpfaden, selbstheilender load_order.toml, drei Phasen und zweistufiger Deserialisierung (03/04) - game/: Attributkatalog, Creature, Game::attribute ueber mehrere Quellen, Command mit Outcome (01/07/11/12) - ui/: Screen-Stack mit Response, Grid-Widget, Press-Filter (06/11/12) - mods/core/: echte TOML-Dateien auf der Platte statt include_str! 10 Tests, darunter der Borrow-Beweis fuer Registries-neben-Game, den Ticket 12 nicht fuehren konnte.
This commit is contained in:
1648
Cargo.lock
generated
1648
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -6,3 +6,6 @@ edition = "2024"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { version = "1.0.229", features = ["derive"] }
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
toml = "1.1.4"
|
toml = "1.1.4"
|
||||||
|
# crossterm kommt als Re-Export mit (Ticket 06/12 — kein eigener Eintrag noetig)
|
||||||
|
ratatui = "0.30.2"
|
||||||
|
directories = "6.0.0"
|
||||||
|
|||||||
48
mods/core/character_options.toml
Normal file
48
mods/core/character_options.toml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Rasse und Klasse sind Charakteroptionen, keine eigenen Konzepte (Ticket 02).
|
||||||
|
# Die Kategorien sind selbst Inhalt und tragen `pick = N`, damit ein Mod eine
|
||||||
|
# dritte Wahl ergaenzen kann, ohne dass die Engine davon weiss.
|
||||||
|
|
||||||
|
[[categories]]
|
||||||
|
id = "race"
|
||||||
|
name = "Rasse"
|
||||||
|
order = 10
|
||||||
|
pick = 1
|
||||||
|
|
||||||
|
[[categories]]
|
||||||
|
id = "class"
|
||||||
|
name = "Klasse"
|
||||||
|
order = 20
|
||||||
|
pick = 1
|
||||||
|
|
||||||
|
[[options]]
|
||||||
|
id = "dwarf"
|
||||||
|
category = "core:race"
|
||||||
|
name = "Zwerg"
|
||||||
|
description = "Zäh, misstrauisch, trinkfest."
|
||||||
|
modifiers = [ { attribute = "constitution", flat = 2 },
|
||||||
|
{ attribute = "max_health", flat = 10 } ]
|
||||||
|
|
||||||
|
[[options]]
|
||||||
|
id = "elf"
|
||||||
|
category = "core:race"
|
||||||
|
name = "Elf"
|
||||||
|
description = "Flink, hochmütig, langlebig."
|
||||||
|
modifiers = [ { attribute = "dexterity", flat = 2 },
|
||||||
|
{ attribute = "luck", flat = 1 } ]
|
||||||
|
|
||||||
|
[[options]]
|
||||||
|
id = "warrior"
|
||||||
|
category = "core:class"
|
||||||
|
name = "Krieger"
|
||||||
|
description = "Schlägt zu, wo andere nachdenken."
|
||||||
|
modifiers = [ { attribute = "strength", flat = 3 },
|
||||||
|
{ attribute = "damage", percent = 10 } ]
|
||||||
|
|
||||||
|
[[options]]
|
||||||
|
id = "thief"
|
||||||
|
category = "core:class"
|
||||||
|
name = "Dieb"
|
||||||
|
description = "Nimmt, was nicht niet- und nagelfest ist."
|
||||||
|
modifiers = [ { attribute = "dexterity", flat = 2 },
|
||||||
|
{ attribute = "luck", flat = 2 },
|
||||||
|
{ attribute = "critical_chance", percent = 15 } ]
|
||||||
38
mods/core/items.toml
Normal file
38
mods/core/items.toml
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Es gibt keinen Item-Typ (Ticket 02). Ein Schwert ist ein Gegenstand mit einem
|
||||||
|
# Modifikator auf `damage`, ein Helm einer mit einem Modifikator auf Rüstung —
|
||||||
|
# der Unterschied steht in den Modifikatoren, nicht in einer Kategorie.
|
||||||
|
|
||||||
|
[[items]]
|
||||||
|
id = "iron_sword"
|
||||||
|
name = "Eisenschwert"
|
||||||
|
base_price = 45
|
||||||
|
slot = "core:hand"
|
||||||
|
symbol = "/"
|
||||||
|
color = "white"
|
||||||
|
modifiers = [ { attribute = "damage", flat = 5 } ]
|
||||||
|
|
||||||
|
[[items]]
|
||||||
|
id = "leather_cap"
|
||||||
|
name = "Lederkappe"
|
||||||
|
base_price = 18
|
||||||
|
slot = "core:head"
|
||||||
|
symbol = "^"
|
||||||
|
color = "yellow"
|
||||||
|
modifiers = [ { attribute = "max_health", flat = 8 } ]
|
||||||
|
|
||||||
|
[[items]]
|
||||||
|
id = "health_potion"
|
||||||
|
name = "Heiltrank"
|
||||||
|
base_price = 12
|
||||||
|
stackable = true
|
||||||
|
consumed_on_use = true
|
||||||
|
symbol = "!"
|
||||||
|
color = "red"
|
||||||
|
|
||||||
|
[[items]]
|
||||||
|
id = "gold"
|
||||||
|
name = "Gold"
|
||||||
|
base_price = 1
|
||||||
|
stackable = true
|
||||||
|
symbol = "$"
|
||||||
|
color = "yellow"
|
||||||
4
mods/core/manifest.toml
Normal file
4
mods/core/manifest.toml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
id = "core"
|
||||||
|
name = "rustkeep"
|
||||||
|
version = "0.1.0"
|
||||||
|
depends = []
|
||||||
17
mods/core/slots.toml
Normal file
17
mods/core/slots.toml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Slots sind Inhalt, kein Aufzaehlungswert — der Charakterbogen rendert die
|
||||||
|
# vorhandenen, statt ein festes Layout zu mauern (Ticket 02).
|
||||||
|
|
||||||
|
[[slots]]
|
||||||
|
id = "head"
|
||||||
|
name = "Kopf"
|
||||||
|
order = 10
|
||||||
|
|
||||||
|
[[slots]]
|
||||||
|
id = "body"
|
||||||
|
name = "Rumpf"
|
||||||
|
order = 20
|
||||||
|
|
||||||
|
[[slots]]
|
||||||
|
id = "hand"
|
||||||
|
name = "Hand"
|
||||||
|
order = 30
|
||||||
535
src/data/loader.rs
Normal file
535
src/data/loader.rs
Normal file
@@ -0,0 +1,535 @@
|
|||||||
|
//! Mod-Ladepipeline (Ticket 03) und Fehlerstrategie (Ticket 04).
|
||||||
|
//!
|
||||||
|
//! Drei Phasen: Manifeste → Einlesen → Aufloesen. In Phase 2 wird nichts
|
||||||
|
//! aufgeloest, dadurch gibt es **keine Vorwaertsreferenzen** — ein Mod darf auf
|
||||||
|
//! etwas zeigen, das erst ein spaeterer liefert.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fmt;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use toml::Spanned;
|
||||||
|
|
||||||
|
use super::registries::{Category, CharacterOption, Item, Registries, Slot};
|
||||||
|
|
||||||
|
/// Genau drei harte Fehler (Ticket 04). Alles Uebrige ist Warnung plus
|
||||||
|
/// Verwerfen des **einzelnen Eintrags**.
|
||||||
|
///
|
||||||
|
/// Handgeschrieben statt `thiserror`: drei Varianten, ein Konsument — und in
|
||||||
|
/// einem Ticket, das Rust lernen soll, ist dieses `match` der Zweck.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum LoadError {
|
||||||
|
CoreMissing,
|
||||||
|
DependencyMissing { module: String, dep: String },
|
||||||
|
DependencyOrder { module: String, dep: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for LoadError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
LoadError::CoreMissing => write!(
|
||||||
|
f,
|
||||||
|
"Das Kernspiel wurde nicht gefunden. Erwartet wird ein Ordner \
|
||||||
|
'mods/core' mit einer manifest.toml."
|
||||||
|
),
|
||||||
|
LoadError::DependencyMissing { module, dep } => {
|
||||||
|
write!(f, "'{module}' braucht '{dep}', aber '{dep}' ist nicht installiert.")
|
||||||
|
}
|
||||||
|
LoadError::DependencyOrder { module, dep } => write!(
|
||||||
|
f,
|
||||||
|
"'{module}' muss nach '{dep}' geladen werden, steht in der \
|
||||||
|
load_order.toml aber davor."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Manifest und Mod-Erkennung
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Manifest {
|
||||||
|
id: String,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
name: String,
|
||||||
|
/// Einziger Leser ist der Save-Header (Ticket 03/09).
|
||||||
|
#[allow(dead_code)]
|
||||||
|
version: String,
|
||||||
|
#[serde(default)]
|
||||||
|
depends: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ModInfo {
|
||||||
|
id: String,
|
||||||
|
depends: Vec<String>,
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zwei Suchpfade (Ticket 03): `mods/` neben der Binary und `mods/` im
|
||||||
|
/// Benutzerprofil. Bei gleicher Mod-ID **gewinnt das Profil** — es existiert
|
||||||
|
/// genau dafuer, dass die Sachen des Spielers eine Neuinstallation ueberleben.
|
||||||
|
fn search_paths() -> Vec<PathBuf> {
|
||||||
|
let mut paths = Vec::new();
|
||||||
|
|
||||||
|
if let Ok(exe) = std::env::current_exe()
|
||||||
|
&& let Some(dir) = exe.parent()
|
||||||
|
{
|
||||||
|
paths.push(dir.join("mods"));
|
||||||
|
}
|
||||||
|
// Entwicklungsfall: `cargo run` legt die Binary nach target/debug/.
|
||||||
|
paths.push(PathBuf::from("mods"));
|
||||||
|
|
||||||
|
if let Some(dir) = profile_dir() {
|
||||||
|
paths.push(dir.join("mods"));
|
||||||
|
}
|
||||||
|
paths
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `data_dir()` ueber die Crate `directories` — Windows, Linux *und* macOS.
|
||||||
|
/// Keine Trennung von Daten und Konfiguration: Mods, `load_order.toml`, Save
|
||||||
|
/// und Logs liegen in einem Ordner (Ticket 03).
|
||||||
|
pub fn profile_dir() -> Option<PathBuf> {
|
||||||
|
directories::ProjectDirs::from("", "", "rustkeep").map(|d| d.data_dir().to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discover(warnings: &mut Vec<String>) -> Vec<ModInfo> {
|
||||||
|
let mut found: HashMap<String, ModInfo> = HashMap::new();
|
||||||
|
|
||||||
|
for base in search_paths() {
|
||||||
|
let Ok(entries) = fs::read_dir(&base) else { continue };
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest_path = path.join("manifest.toml");
|
||||||
|
let Ok(raw) = fs::read_to_string(&manifest_path) else {
|
||||||
|
// Ordner ohne Manifest ist kein Mod — sonst legt eine vergessene
|
||||||
|
// screenshots/-Kopie das Spiel lahm (Ticket 03).
|
||||||
|
warnings.push(format!("{}: kein manifest.toml, übersprungen", path.display()));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
match toml::from_str::<Manifest>(&raw) {
|
||||||
|
Ok(m) => {
|
||||||
|
if let Some(old) = found.get(&m.id) {
|
||||||
|
warnings.push(format!(
|
||||||
|
"'{}' liegt zweimal: {} gewinnt gegen {}",
|
||||||
|
m.id,
|
||||||
|
path.display(),
|
||||||
|
old.path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
found.insert(
|
||||||
|
m.id.clone(),
|
||||||
|
ModInfo { id: m.id, depends: m.depends, path },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => warnings.push(format!("{}: {}", manifest_path.display(), e.message())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
found.into_values().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Ladereihenfolge — selbstheilend (Ticket 03)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize, serde::Serialize)]
|
||||||
|
struct LoadOrder {
|
||||||
|
#[serde(default)]
|
||||||
|
mods: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_order(found: &[ModInfo], warnings: &mut Vec<String>) -> Vec<String> {
|
||||||
|
let file = profile_dir().map(|d| d.join("load_order.toml"));
|
||||||
|
|
||||||
|
let mut order: Vec<String> = file
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| fs::read_to_string(p).ok())
|
||||||
|
.and_then(|raw| toml::from_str::<LoadOrder>(&raw).ok())
|
||||||
|
.map(|lo| lo.mods)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Eintrag ohne Ordner: Warnzeile und ueberspringen. Das ist der Normalfall
|
||||||
|
// nach dem Deinstallieren und darf den Start nicht verhindern.
|
||||||
|
order.retain(|id| {
|
||||||
|
let exists = found.iter().any(|m| &m.id == id);
|
||||||
|
if !exists {
|
||||||
|
warnings.push(format!("'{id}' steht in load_order.toml, ist aber nicht installiert"));
|
||||||
|
}
|
||||||
|
exists
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ordner ohne Eintrag: hinten anhaengen. Ein entpackter Mod laeuft damit,
|
||||||
|
// ohne dass der Spieler von der Sortierdatei wissen muss.
|
||||||
|
let mut fresh: Vec<&str> = found
|
||||||
|
.iter()
|
||||||
|
.filter(|m| !order.contains(&m.id))
|
||||||
|
.map(|m| m.id.as_str())
|
||||||
|
.collect();
|
||||||
|
fresh.sort_unstable();
|
||||||
|
|
||||||
|
if order.is_empty() {
|
||||||
|
// Erststart: core zuerst, Rest alphabetisch.
|
||||||
|
if let Some(pos) = fresh.iter().position(|id| *id == "core") {
|
||||||
|
order.push(fresh.remove(pos).to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
order.extend(fresh.into_iter().map(str::to_string));
|
||||||
|
|
||||||
|
if let Some(path) = file {
|
||||||
|
let _ = fs::create_dir_all(path.parent().unwrap());
|
||||||
|
if let Ok(text) = toml::to_string(&LoadOrder { mods: order.clone() }) {
|
||||||
|
// Schreibfehler wird verschluckt — ein Spiel, das wegen seiner
|
||||||
|
// Sortierdatei nicht startet, waere die Karikatur guter
|
||||||
|
// Fehlerbehandlung (Ticket 04).
|
||||||
|
let _ = fs::write(&path, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
order
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `depends` sortiert nicht, es **prueft** — das ist der Preis dafuer, dass ein
|
||||||
|
/// Mensch die Reihenfolge pflegt (Ticket 03).
|
||||||
|
fn check_depends(found: &[ModInfo], order: &[String]) -> Result<(), LoadError> {
|
||||||
|
for (i, id) in order.iter().enumerate() {
|
||||||
|
let Some(info) = found.iter().find(|m| &m.id == id) else { continue };
|
||||||
|
|
||||||
|
for dep in &info.depends {
|
||||||
|
match order.iter().position(|o| o == dep) {
|
||||||
|
None => {
|
||||||
|
return Err(LoadError::DependencyMissing {
|
||||||
|
module: id.clone(),
|
||||||
|
dep: dep.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Some(j) if j > i => {
|
||||||
|
return Err(LoadError::DependencyOrder {
|
||||||
|
module: id.clone(),
|
||||||
|
dep: dep.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Some(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Phase 2 — Einlesen, zweistufig (Ticket 04)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Jede Datei darf jede Registry-Tabelle enthalten (Ticket 03). `toml::Value`
|
||||||
|
/// als Zwischenstufe ist der Grund, warum ein kaputter Eintrag nur *sich selbst*
|
||||||
|
/// mitreisst und nicht die ganze Datei.
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
struct ModFile {
|
||||||
|
#[serde(default)]
|
||||||
|
items: Vec<Spanned<toml::Value>>,
|
||||||
|
#[serde(default)]
|
||||||
|
slots: Vec<Spanned<toml::Value>>,
|
||||||
|
#[serde(default)]
|
||||||
|
categories: Vec<Spanned<toml::Value>>,
|
||||||
|
#[serde(default)]
|
||||||
|
options: Vec<Spanned<toml::Value>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Damit die vier Registry-Typen durch dieselbe Einlese-Schleife passen.
|
||||||
|
trait Entry: DeserializeOwned {
|
||||||
|
fn id(&self) -> &str;
|
||||||
|
fn set_id(&mut self, id: String);
|
||||||
|
fn overrides(&self) -> Option<&str>;
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! impl_entry {
|
||||||
|
($t:ty) => {
|
||||||
|
impl Entry for $t {
|
||||||
|
fn id(&self) -> &str {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
fn set_id(&mut self, id: String) {
|
||||||
|
self.id = id;
|
||||||
|
}
|
||||||
|
fn overrides(&self) -> Option<&str> {
|
||||||
|
self.overrides.as_deref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
impl_entry!(Item);
|
||||||
|
impl_entry!(Slot);
|
||||||
|
impl_entry!(Category);
|
||||||
|
impl_entry!(CharacterOption);
|
||||||
|
|
||||||
|
struct FileCtx<'a> {
|
||||||
|
module: &'a str,
|
||||||
|
/// Pfad relativ zum Mod-Ordner — die Mod-ID steht ohnehin vorn.
|
||||||
|
file: String,
|
||||||
|
source: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zeile aus dem Byte-Offset. `toml::de::Error` und `Spanned` liefern nur
|
||||||
|
/// Offsets, keine Zeilen (gegen die Doku geprueft).
|
||||||
|
fn line_of(source: &str, offset: usize) -> usize {
|
||||||
|
source[..offset.min(source.len())].matches('\n').count() + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_entries<T: Entry>(
|
||||||
|
raw: Vec<Spanned<toml::Value>>,
|
||||||
|
ctx: &FileCtx<'_>,
|
||||||
|
into: &mut HashMap<String, T>,
|
||||||
|
warnings: &mut Vec<String>,
|
||||||
|
) {
|
||||||
|
for (index, spanned) in raw.into_iter().enumerate() {
|
||||||
|
let line = line_of(ctx.source, spanned.span().start);
|
||||||
|
let value = spanned.into_inner();
|
||||||
|
|
||||||
|
let mut entry = match T::deserialize(value) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
// Ein Eintrag ohne lesbare `id` kann sich nicht benennen und
|
||||||
|
// heisst nach seiner Position (Ticket 04).
|
||||||
|
warnings.push(format!(
|
||||||
|
"{}/{}:{}: [{}] — {}",
|
||||||
|
ctx.module, ctx.file, line, index, e.message()
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Der Lader stellt manifest.id automatisch voran (Ticket 03).
|
||||||
|
let own_id = format!("{}:{}", ctx.module, entry.id());
|
||||||
|
|
||||||
|
// `overrides` ersetzt den genannten Eintrag und uebernimmt dessen ID —
|
||||||
|
// die eigene wird dann gar nicht registriert (Ticket 03).
|
||||||
|
let key = match entry.overrides() {
|
||||||
|
Some(target) => {
|
||||||
|
let target = target.to_string();
|
||||||
|
if !into.contains_key(&target) {
|
||||||
|
warnings.push(format!(
|
||||||
|
"{}/{}:{}: {} überschreibt '{}', das es nicht gibt — verworfen",
|
||||||
|
ctx.module, ctx.file, line, own_id, target
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
warnings.push(format!("{} überschreibt '{}'", ctx.module, target));
|
||||||
|
target
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
if into.contains_key(&own_id) {
|
||||||
|
warnings.push(format!("{} überschreibt '{}'", ctx.module, own_id));
|
||||||
|
}
|
||||||
|
own_id
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
entry.set_id(key.clone());
|
||||||
|
into.insert(key, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Phase 3 — Aufloesen
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
fn resolve(reg: &mut Registries, warnings: &mut Vec<String>) {
|
||||||
|
let known_slots: Vec<String> = reg.slots.keys().cloned().collect();
|
||||||
|
reg.items.retain(|id, item| match &item.slot {
|
||||||
|
Some(slot) if !known_slots.contains(slot) => {
|
||||||
|
warnings.push(format!("{id}: Slot '{slot}' existiert nicht — verworfen"));
|
||||||
|
false
|
||||||
|
}
|
||||||
|
_ => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
let known_categories: Vec<String> = reg.categories.keys().cloned().collect();
|
||||||
|
reg.options.retain(|id, option| {
|
||||||
|
if known_categories.contains(&option.category) {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
warnings.push(format!(
|
||||||
|
"{id}: Kategorie '{}' existiert nicht — verworfen",
|
||||||
|
option.category
|
||||||
|
));
|
||||||
|
false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Einstiegspunkt
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
pub fn load_mods() -> Result<(Registries, Vec<String>), LoadError> {
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
|
||||||
|
// Phase 1 — Manifeste, Reihenfolge, depends
|
||||||
|
let found = discover(&mut warnings);
|
||||||
|
if !found.iter().any(|m| m.id == "core") {
|
||||||
|
return Err(LoadError::CoreMissing);
|
||||||
|
}
|
||||||
|
let order = resolve_order(&found, &mut warnings);
|
||||||
|
check_depends(&found, &order)?;
|
||||||
|
|
||||||
|
// Phase 2 — Einlesen in Ladereihenfolge
|
||||||
|
let mut reg = Registries::default();
|
||||||
|
for id in &order {
|
||||||
|
let Some(info) = found.iter().find(|m| &m.id == id) else { continue };
|
||||||
|
|
||||||
|
for file in toml_files(&info.path) {
|
||||||
|
let name = file
|
||||||
|
.strip_prefix(&info.path)
|
||||||
|
.unwrap_or(&file)
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
|
||||||
|
let Ok(source) = fs::read_to_string(&file) else { continue };
|
||||||
|
|
||||||
|
// Kaputte Syntax reisst die ganze Datei mit — da kommt der Parser
|
||||||
|
// schlicht nicht weiter. Das ist keine Entscheidung, sondern eine
|
||||||
|
// Tatsache (Ticket 04).
|
||||||
|
let parsed: ModFile = match toml::from_str(&source) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
let line = e.span().map(|s| line_of(&source, s.start)).unwrap_or(0);
|
||||||
|
warnings.push(format!("{}/{}:{}: {}", info.id, name, line, e.message()));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let ctx = FileCtx { module: &info.id, file: name, source: &source };
|
||||||
|
parse_entries(parsed.items, &ctx, &mut reg.items, &mut warnings);
|
||||||
|
parse_entries(parsed.slots, &ctx, &mut reg.slots, &mut warnings);
|
||||||
|
parse_entries(parsed.categories, &ctx, &mut reg.categories, &mut warnings);
|
||||||
|
parse_entries(parsed.options, &ctx, &mut reg.options, &mut warnings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3 — Referenzen aufloesen
|
||||||
|
resolve(&mut reg, &mut warnings);
|
||||||
|
|
||||||
|
Ok((reg, warnings))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toml_files(dir: &Path) -> Vec<PathBuf> {
|
||||||
|
let mut files: Vec<PathBuf> = fs::read_dir(dir)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.flatten()
|
||||||
|
.map(|e| e.path())
|
||||||
|
.filter(|p| p.extension().is_some_and(|e| e == "toml"))
|
||||||
|
.filter(|p| p.file_name().is_some_and(|n| n != "manifest.toml"))
|
||||||
|
.collect();
|
||||||
|
files.sort();
|
||||||
|
files
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Warnungen ins Log (Ticket 04)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Schreibt die Warnliste, behaelt die letzten zehn Laeufe und gibt den
|
||||||
|
/// Dateinamen zurueck. Ein sauberer Lauf legt keine Datei an.
|
||||||
|
pub fn write_log(warnings: &[String]) -> Option<String> {
|
||||||
|
if warnings.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let dir = profile_dir()?.join("logs");
|
||||||
|
fs::create_dir_all(&dir).ok()?;
|
||||||
|
|
||||||
|
// ponytail: Sekunden seit Epoch statt JJJJMMTT-HHMMSS. Sortierbar, und die
|
||||||
|
// Alternative waere Datumsarithmetik von Hand oder eine Crate dafuer.
|
||||||
|
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
|
||||||
|
let name = format!("load-{stamp}.log");
|
||||||
|
fs::write(dir.join(&name), warnings.join("\n")).ok()?;
|
||||||
|
|
||||||
|
let mut old: Vec<PathBuf> = fs::read_dir(&dir)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.flatten()
|
||||||
|
.map(|e| e.path())
|
||||||
|
.filter(|p| {
|
||||||
|
p.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.is_some_and(|n| n.starts_with("load-") && n.ends_with(".log"))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
old.sort();
|
||||||
|
for path in old.iter().rev().skip(10) {
|
||||||
|
let _ = fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zeilennummer_aus_offset() {
|
||||||
|
let src = "a\nb\nc";
|
||||||
|
assert_eq!(line_of(src, 0), 1);
|
||||||
|
assert_eq!(line_of(src, 2), 2);
|
||||||
|
assert_eq!(line_of(src, 4), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Check aus Ticket 04: Ein kaputtes Kernspiel faellt beim `cargo test`
|
||||||
|
/// auf, nicht beim Spieler — und die Ladelogik bleibt frei von Sonderregeln.
|
||||||
|
#[test]
|
||||||
|
fn core_laedt_ohne_warnungen() {
|
||||||
|
let (reg, warnings) = load_mods().expect("core muss laden");
|
||||||
|
let relevant: Vec<&String> =
|
||||||
|
warnings.iter().filter(|w| !w.contains("load_order.toml")).collect();
|
||||||
|
assert!(relevant.is_empty(), "core lädt nicht sauber: {relevant:#?}");
|
||||||
|
assert!(reg.items.contains_key("core:iron_sword"));
|
||||||
|
assert!(reg.slots.contains_key("core:hand"));
|
||||||
|
assert!(reg.options.contains_key("core:dwarf"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ticket 04 versprach, dass ein kaputter Eintrag nur *sich selbst*
|
||||||
|
/// mitreisst. Das ist mit serde nicht gratis — hier wird es geprueft.
|
||||||
|
#[test]
|
||||||
|
fn kaputter_eintrag_reisst_die_nachbarn_nicht_mit() {
|
||||||
|
let source = r#"
|
||||||
|
[[items]]
|
||||||
|
id = "gut"
|
||||||
|
name = "Gut"
|
||||||
|
|
||||||
|
[[items]]
|
||||||
|
id = "kaputt"
|
||||||
|
name = 42
|
||||||
|
|
||||||
|
[[items]]
|
||||||
|
id = "auch_gut"
|
||||||
|
name = "Auch gut"
|
||||||
|
"#;
|
||||||
|
let parsed: ModFile = toml::from_str(source).expect("Syntax ist in Ordnung");
|
||||||
|
let ctx = FileCtx { module: "test", file: "items.toml".into(), source };
|
||||||
|
let mut items: HashMap<String, Item> = HashMap::new();
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
parse_entries(parsed.items, &ctx, &mut items, &mut warnings);
|
||||||
|
|
||||||
|
assert_eq!(items.len(), 2, "die beiden heilen Eintraege muessen ueberleben");
|
||||||
|
assert!(items.contains_key("test:gut"));
|
||||||
|
assert!(items.contains_key("test:auch_gut"));
|
||||||
|
assert_eq!(warnings.len(), 1);
|
||||||
|
// `Spanned` zeigt auf den Anfang des Eintrags (`[[items]]`, Zeile 6),
|
||||||
|
// nicht auf das schuldige Feld — genau der Zeilenbereich, den Ticket 04
|
||||||
|
// zugesagt hat. Der Feldname steht dafuer in `message()`.
|
||||||
|
assert!(warnings[0].contains("items.toml:6"), "Zeile fehlt: {}", warnings[0]);
|
||||||
|
assert!(warnings[0].contains("expected a string"));
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/data/mod.rs
Normal file
8
src/data/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
//! Definitionen und Mod-Lader. Kennt ratatui nicht — Farben sind hier Namen,
|
||||||
|
//! uebersetzt wird in `ui/theme.rs` (Ticket 11).
|
||||||
|
|
||||||
|
pub mod loader;
|
||||||
|
pub mod registries;
|
||||||
|
|
||||||
|
pub use loader::{load_mods, write_log};
|
||||||
|
pub use registries::Registries;
|
||||||
116
src/data/registries.rs
Normal file
116
src/data/registries.rs
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
//! Die Definitionen, die aus TOML geladen werden — Ticket 02.
|
||||||
|
//!
|
||||||
|
//! Einige Felder liest in M1 noch niemand: `base_price` wartet auf den Handel,
|
||||||
|
//! `stackable` und `consumed_on_use` aufs Inventar, `symbol` auf Gegenstaende,
|
||||||
|
//! die auf dem Boden liegen. Sie stehen trotzdem hier, weil sie **das
|
||||||
|
//! Datenmodell** sind und `mods/core` sie bereits schreibt — sie erst spaeter
|
||||||
|
//! einzufuehren hiesse, die TOML-Dateien zweimal anzufassen.
|
||||||
|
//!
|
||||||
|
//! ponytail: modulweites `allow` — es versteckt ab M2 auch echte Leichen.
|
||||||
|
//! Raus, sobald Handel und Inventar die Felder tatsaechlich lesen.
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::game::Modifier;
|
||||||
|
|
||||||
|
/// Ein Ding im Spiel, **einheitlich strukturiert**. Es gibt keinen Item-Typ:
|
||||||
|
/// `enum ItemType` vermischte Slot, Verhalten und Kategorie (Ticket 02).
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Item {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub base_price: u32,
|
||||||
|
/// Leer = nicht ausruestbar.
|
||||||
|
pub slot: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub stackable: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub consumed_on_use: bool,
|
||||||
|
#[serde(default = "default_symbol")]
|
||||||
|
pub symbol: char,
|
||||||
|
#[serde(default)]
|
||||||
|
pub color: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub modifiers: Vec<Modifier>,
|
||||||
|
/// Ersetzt den genannten Eintrag und uebernimmt dessen ID (Ticket 03).
|
||||||
|
#[serde(default)]
|
||||||
|
pub overrides: Option<String>,
|
||||||
|
// TODO(M3+): `actions`, `abilities`, `triggers` — brauchen erst Kampf und
|
||||||
|
// Event-System.
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_symbol() -> char {
|
||||||
|
'?'
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine benannte Stelle am Charakter. Moddbarer Inhalt, kein fester
|
||||||
|
/// Aufzaehlungswert (Ticket 02).
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Slot {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub order: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub overrides: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kategorie von Charakteroptionen — selbst Inhalt, mit `pick = N`.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Category {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub order: i32,
|
||||||
|
#[serde(default = "one")]
|
||||||
|
pub pick: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub overrides: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn one() -> u32 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Wahl beim Charakterbau. Rasse und Klasse sind beides davon.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct CharacterOption {
|
||||||
|
pub id: String,
|
||||||
|
pub category: String,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub modifiers: Vec<Modifier>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub overrides: Option<String>,
|
||||||
|
// TODO(M3): `abilities` und `on_create` (Ticket 02) — Faehigkeiten und
|
||||||
|
// Startausruestung brauchen Kampf bzw. Inventar.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alle geladenen Definitionen. Liegt **neben** `Game`, nicht darin — als Feld
|
||||||
|
/// kollidierte `&mut game.player` mit `&game.registries` bei jedem
|
||||||
|
/// Methodenaufruf (Ticket 07).
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Registries {
|
||||||
|
pub items: HashMap<String, Item>,
|
||||||
|
pub slots: HashMap<String, Slot>,
|
||||||
|
pub categories: HashMap<String, Category>,
|
||||||
|
pub options: HashMap<String, CharacterOption>,
|
||||||
|
// TODO(M2+): `affixes`, `abilities`, `quests`, `creatures`, `prefabs`,
|
||||||
|
// `floors`, `loot_tables`, `scripts` — zwoelf sind es am Ende.
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Registries {
|
||||||
|
/// Slots in Anzeigereihenfolge. Der Charakterbogen schleift darueber,
|
||||||
|
/// statt ein Layout zu mauern (Ticket 02).
|
||||||
|
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()));
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
161
src/game/attributes.rs
Normal file
161
src/game/attributes.rs
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
//! Attributkatalog und Modifikatoren — Ticket 01.
|
||||||
|
//!
|
||||||
|
//! Der Katalog ist **fest** und Engine-Vokabular ohne Namensraum. Ein Modder
|
||||||
|
//! kann ihn nicht erweitern; das ist Absicht (siehe Glossar, „Attribut").
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// 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).
|
||||||
|
///
|
||||||
|
// TODO(M3): `resistance` fehlt — es ist typ-indiziert (sieben Schadenstypen aus
|
||||||
|
// Ticket 01) und braucht damit eine andere Form als die uebrigen.
|
||||||
|
// Kommt mit dem Kampf.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[repr(usize)]
|
||||||
|
pub enum Attribute {
|
||||||
|
Strength,
|
||||||
|
Dexterity,
|
||||||
|
Constitution,
|
||||||
|
Charisma,
|
||||||
|
Luck,
|
||||||
|
Level,
|
||||||
|
MaxHealth,
|
||||||
|
MaxStamina,
|
||||||
|
Damage,
|
||||||
|
HitChance,
|
||||||
|
CriticalChance,
|
||||||
|
CriticalFactor,
|
||||||
|
PriceModifier,
|
||||||
|
CarryCapacity,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Attribute {
|
||||||
|
pub const COUNT: usize = 14;
|
||||||
|
|
||||||
|
pub const ALL: [Attribute; Self::COUNT] = [
|
||||||
|
Attribute::Strength,
|
||||||
|
Attribute::Dexterity,
|
||||||
|
Attribute::Constitution,
|
||||||
|
Attribute::Charisma,
|
||||||
|
Attribute::Luck,
|
||||||
|
Attribute::Level,
|
||||||
|
Attribute::MaxHealth,
|
||||||
|
Attribute::MaxStamina,
|
||||||
|
Attribute::Damage,
|
||||||
|
Attribute::HitChance,
|
||||||
|
Attribute::CriticalChance,
|
||||||
|
Attribute::CriticalFactor,
|
||||||
|
Attribute::PriceModifier,
|
||||||
|
Attribute::CarryCapacity,
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Attribute::Strength => "Stärke",
|
||||||
|
Attribute::Dexterity => "Geschicklichkeit",
|
||||||
|
Attribute::Constitution => "Konstitution",
|
||||||
|
Attribute::Charisma => "Charisma",
|
||||||
|
Attribute::Luck => "Glück",
|
||||||
|
Attribute::Level => "Level",
|
||||||
|
Attribute::MaxHealth => "Leben (max)",
|
||||||
|
Attribute::MaxStamina => "Ausdauer (max)",
|
||||||
|
Attribute::Damage => "Schaden",
|
||||||
|
Attribute::HitChance => "Trefferchance",
|
||||||
|
Attribute::CriticalChance => "Kritische Chance",
|
||||||
|
Attribute::CriticalFactor => "Kritischer Faktor",
|
||||||
|
Attribute::PriceModifier => "Preis-Modifikator",
|
||||||
|
Attribute::CarryCapacity => "Tragfähigkeit",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)]
|
||||||
|
pub struct Attributes([f32; Attribute::COUNT]);
|
||||||
|
|
||||||
|
impl Attributes {
|
||||||
|
pub fn get(&self, a: Attribute) -> f32 {
|
||||||
|
self.0[a as usize]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(&mut self, a: Attribute, v: f32) {
|
||||||
|
self.0[a as usize] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Attributes {
|
||||||
|
/// Startwerte eines frischen Helden. Zahlen sind bewusst grob — Balancing
|
||||||
|
/// setzt ein spielbares Spiel voraus (siehe Map, „Not yet specified").
|
||||||
|
fn default() -> Self {
|
||||||
|
let mut a = Attributes([0.0; Attribute::COUNT]);
|
||||||
|
a.set(Attribute::Strength, 1.0);
|
||||||
|
a.set(Attribute::Dexterity, 1.0);
|
||||||
|
a.set(Attribute::Constitution, 1.0);
|
||||||
|
a.set(Attribute::Charisma, 1.0);
|
||||||
|
a.set(Attribute::Luck, 1.0);
|
||||||
|
a.set(Attribute::Level, 1.0);
|
||||||
|
a.set(Attribute::MaxHealth, 100.0);
|
||||||
|
a.set(Attribute::MaxStamina, 100.0);
|
||||||
|
a.set(Attribute::Damage, 2.0);
|
||||||
|
a.set(Attribute::HitChance, 0.8);
|
||||||
|
a.set(Attribute::CriticalFactor, 1.5);
|
||||||
|
a.set(Attribute::CarryCapacity, 50.0);
|
||||||
|
a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rechnet ein Attribut um, solange er existiert — Ticket 01. Er wird nie
|
||||||
|
/// ausgefuehrt, sondern beim Auslesen aufsummiert.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Modifier {
|
||||||
|
pub attribute: Attribute,
|
||||||
|
#[serde(default)]
|
||||||
|
pub flat: f32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub percent: f32,
|
||||||
|
// TODO(M3): `duration`, `target` und `condition` aus Ticket 01/08. In M1
|
||||||
|
// sind alle Modifikatoren dauerhaft und wirken auf den Traeger.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stapelregel aus Ticket 01: `(Basis + Σ flach) × (1 + Σ prozentual)`,
|
||||||
|
/// intern `f32`, gerundet an genau einer Stelle — hier gar nicht, weil das
|
||||||
|
/// Runden dorthin gehoert, wo ein Wert angezeigt oder verrechnet wird.
|
||||||
|
pub fn stack<'a>(base: f32, modifiers: impl Iterator<Item = &'a Modifier>, attr: Attribute) -> f32 {
|
||||||
|
let (flat, percent) = modifiers
|
||||||
|
.filter(|m| m.attribute == attr)
|
||||||
|
.fold((0.0, 0.0), |(f, p), m| (f + m.flat, p + m.percent));
|
||||||
|
(base + flat) * (1.0 + percent / 100.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// `ALL` wird von Hand neben dem Enum gepflegt. Wer eine Variante ergaenzt
|
||||||
|
/// und die Liste vergisst, faellt sonst nirgends auf — der Charakterbogen
|
||||||
|
/// zeigt das Attribut dann einfach nie.
|
||||||
|
#[test]
|
||||||
|
fn all_ist_vollstaendig_und_in_indexreihenfolge() {
|
||||||
|
assert_eq!(Attribute::ALL.len(), Attribute::COUNT);
|
||||||
|
for (i, a) in Attribute::ALL.iter().enumerate() {
|
||||||
|
assert_eq!(*a as usize, i, "{a:?} steht an der falschen Stelle in ALL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stapelregel_erst_flach_dann_prozentual() {
|
||||||
|
let mods = [
|
||||||
|
Modifier { attribute: Attribute::Strength, flat: 2.0, percent: 0.0 },
|
||||||
|
Modifier { attribute: Attribute::Strength, flat: 3.0, percent: 10.0 },
|
||||||
|
// Fremdes Attribut darf nicht mitzaehlen:
|
||||||
|
Modifier { attribute: Attribute::Damage, flat: 100.0, percent: 0.0 },
|
||||||
|
];
|
||||||
|
// (10 + 5) * 1.10 == 16.5, nicht 10*1.1 + 5 == 16.0
|
||||||
|
assert_eq!(stack(10.0, mods.iter(), Attribute::Strength), 16.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/game/command.rs
Normal file
32
src/game/command.rs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
//! Kommandos der Oberflaeche an das Spiel — Ticket 11.
|
||||||
|
//!
|
||||||
|
//! **Nicht** zu verwechseln mit `Action`, dem TOML-Effektvokabular aus Ticket
|
||||||
|
//! 01. „Geh nach Norden" ist keine der zehn Aktionen und wird nie eine.
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum Direction {
|
||||||
|
North,
|
||||||
|
South,
|
||||||
|
West,
|
||||||
|
East,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ticket 11 hat zwoelf Commands festgelegt; M1 braucht zwei davon.
|
||||||
|
///
|
||||||
|
// TODO(M2+): `UseAbility`, `UseItem`, `Equip`, `Unequip`, `Drop`, `PickUp`,
|
||||||
|
// `Descend`, `Talk`, `Buy`, `Sell` — und `AssignPoints` aus 15.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Command {
|
||||||
|
Move(Direction),
|
||||||
|
Wait,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ergebnis eines Kommandos — der Befund aus dem Durchstich in Ticket 12:
|
||||||
|
/// „alle Commands kosten einen Zug" heisst **alle ausgefuehrten**. Gegen eine
|
||||||
|
/// Wand zu laufen darf keine Zeit verbrauchen, sonst tickt ein Gift-Debuff,
|
||||||
|
/// weil jemand danebengetippt hat.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Outcome {
|
||||||
|
TurnPassed,
|
||||||
|
Rejected(&'static str),
|
||||||
|
}
|
||||||
73
src/game/creature.rs
Normal file
73
src/game/creature.rs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
//! Wesen — Held und Gegner sind derselbe Typ (Ticket 07).
|
||||||
|
//!
|
||||||
|
//! `health`, `stamina`, `tags` und `CreatureRef::Enemy` liest in M1 noch
|
||||||
|
//! niemand: Sie brauchen den Kampf. Sie stehen trotzdem hier, weil sie die
|
||||||
|
//! Struktur aus Ticket 07 sind und nicht deren halbe Vorstufe.
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::attributes::Attributes;
|
||||||
|
|
||||||
|
/// Ein Exemplar eines Gegenstands. Traegt **nur IDs** — die Affixe sind
|
||||||
|
/// Registry-Eintraege, keine eingebetteten Modifikatoren (Ticket 07). Genau
|
||||||
|
/// deshalb bleibt der Speicherstand eine ID-Liste.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ItemInstance {
|
||||||
|
pub def: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub affixes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ItemInstance {
|
||||||
|
pub fn new(def: impl Into<String>) -> Self {
|
||||||
|
Self { def: def.into(), affixes: Vec::new() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adresse eines Wesens. Faellt direkt aus der Zwei-Felder-Entscheidung in
|
||||||
|
/// Ticket 07: Held und Gegner liegen getrennt, es gibt also keinen
|
||||||
|
/// gemeinsamen Index.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum CreatureRef {
|
||||||
|
Player,
|
||||||
|
Enemy(usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
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,
|
||||||
|
/// 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>,
|
||||||
|
// TODO(M3): `modifiers: Vec<ActiveModifier>` (nur befristete),
|
||||||
|
// `initiative: i32`, `cooldowns: HashMap<AbilityId, u32>`.
|
||||||
|
// Alle drei brauchen den Kampf, um ueberhaupt etwas zu tun.
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Creature {
|
||||||
|
pub fn new(name: impl Into<String>) -> Self {
|
||||||
|
let base = Attributes::default();
|
||||||
|
Self {
|
||||||
|
name: name.into(),
|
||||||
|
health: base.get(super::attributes::Attribute::MaxHealth),
|
||||||
|
stamina: base.get(super::attributes::Attribute::MaxStamina),
|
||||||
|
base,
|
||||||
|
options: Vec::new(),
|
||||||
|
inventory: Vec::new(),
|
||||||
|
equipment: HashMap::new(),
|
||||||
|
tags: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/game/floor.rs
Normal file
91
src/game/floor.rs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
//! Etage — Ticket 10 entscheidet den Innenbau, M1 baut einen Testraum von Hand.
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Tile {
|
||||||
|
Wall,
|
||||||
|
Floor,
|
||||||
|
Door,
|
||||||
|
Stairs,
|
||||||
|
Entrance,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Floor {
|
||||||
|
pub number: u8,
|
||||||
|
pub width: u16,
|
||||||
|
pub height: u16,
|
||||||
|
pub tiles: Vec<Tile>,
|
||||||
|
// TODO(M2): `rooms: Vec<Room>` und `player_room: Option<usize>` aus Ticket
|
||||||
|
// 10 — mit ihnen kommen `room_entered`, Kampfbeginn und die
|
||||||
|
// Raum-Beutetabellen. M1 hat genau einen Raum und braucht die
|
||||||
|
// Buchfuehrung noch nicht.
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Floor {
|
||||||
|
pub fn tile(&self, x: u16, y: u16) -> Tile {
|
||||||
|
if x >= self.width || y >= self.height {
|
||||||
|
return Tile::Wall;
|
||||||
|
}
|
||||||
|
self.tiles[y as usize * self.width as usize + x as usize]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handgebauter Testraum. Ticket 10 legt fest, dass Prefabs als ASCII-Bloecke
|
||||||
|
/// geschrieben werden — deshalb steht der Raum hier schon in derselben Form,
|
||||||
|
/// auch wenn der Generator erst in M2 kommt.
|
||||||
|
pub fn test_room() -> Self {
|
||||||
|
const LAYOUT: &[&str] = &[
|
||||||
|
"########################",
|
||||||
|
"#......................#",
|
||||||
|
"#..####....######......#",
|
||||||
|
"#..#..........#........#",
|
||||||
|
"#..#..........#...##...#",
|
||||||
|
"#.....####....#...##...#",
|
||||||
|
"#........#........>....#",
|
||||||
|
"#..<.....#.............#",
|
||||||
|
"########################",
|
||||||
|
];
|
||||||
|
|
||||||
|
let width = LAYOUT[0].len() as u16;
|
||||||
|
let height = LAYOUT.len() as u16;
|
||||||
|
let tiles = LAYOUT
|
||||||
|
.iter()
|
||||||
|
.flat_map(|row| row.chars())
|
||||||
|
.map(|c| match c {
|
||||||
|
'#' => Tile::Wall,
|
||||||
|
'>' => Tile::Stairs,
|
||||||
|
'<' => Tile::Entrance,
|
||||||
|
'+' => Tile::Door,
|
||||||
|
_ => Tile::Floor,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Self { number: 1, width, height, tiles }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Startfeld: das `<` im Layout, sonst das erste begehbare Feld.
|
||||||
|
pub fn entrance(&self) -> (u16, u16) {
|
||||||
|
for y in 0..self.height {
|
||||||
|
for x in 0..self.width {
|
||||||
|
if self.tile(x, y) == Tile::Entrance {
|
||||||
|
return (x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(1, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn testraum_ist_rechteckig_und_hat_eingang() {
|
||||||
|
let f = Floor::test_room();
|
||||||
|
assert_eq!(f.tiles.len(), f.width as usize * f.height as usize);
|
||||||
|
assert_eq!(f.tile(f.entrance().0, f.entrance().1), Tile::Entrance);
|
||||||
|
// Rand ist geschlossen, sonst laeuft der Held aus der Karte.
|
||||||
|
assert_eq!(f.tile(0, 0), Tile::Wall);
|
||||||
|
assert_eq!(f.tile(f.width - 1, f.height - 1), Tile::Wall);
|
||||||
|
}
|
||||||
|
}
|
||||||
191
src/game/mod.rs
Normal file
191
src/game/mod.rs
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
//! Der Spielkern. **Kennt ratatui nicht** — `tests/boundaries.rs` prueft das.
|
||||||
|
|
||||||
|
pub mod attributes;
|
||||||
|
pub mod command;
|
||||||
|
pub mod creature;
|
||||||
|
pub mod floor;
|
||||||
|
|
||||||
|
pub use attributes::{Attribute, Modifier};
|
||||||
|
pub use command::{Command, Direction, Outcome};
|
||||||
|
pub use creature::{Creature, CreatureRef, ItemInstance};
|
||||||
|
pub use floor::{Floor, Tile};
|
||||||
|
|
||||||
|
use crate::data::Registries;
|
||||||
|
|
||||||
|
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,
|
||||||
|
/// Letzte Meldung fuer die Statuszeile.
|
||||||
|
pub last: String,
|
||||||
|
// TODO(M3): `events: Vec<Event>` (Warteschlange aus Ticket 08) und der
|
||||||
|
// xorshift-Seed aus Ticket 14. Beide brauchen erst einen Trigger
|
||||||
|
// bzw. einen Wurf, um etwas zu tun.
|
||||||
|
// TODO(M4): `quests`, `current_exp`, `unspent_points` (Ticket 15 — an
|
||||||
|
// `Game`, nicht am `Creature`).
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Game {
|
||||||
|
pub fn new(player: Creature) -> Self {
|
||||||
|
let floor = Floor::test_room();
|
||||||
|
let player_pos = floor.entrance();
|
||||||
|
Self {
|
||||||
|
player,
|
||||||
|
enemies: Vec::new(),
|
||||||
|
floor,
|
||||||
|
player_pos,
|
||||||
|
turn: 0,
|
||||||
|
last: String::from("—"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn creature(&self, who: CreatureRef) -> &Creature {
|
||||||
|
match who {
|
||||||
|
CreatureRef::Player => &self.player,
|
||||||
|
CreatureRef::Enemy(i) => &self.enemies[i],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Lesezugang liegt auf `Game`, nicht auf `Creature` — eine Aura wohnt
|
||||||
|
/// beim Traeger und wird beim Auslesen des Ziels eingesammelt (Ticket 01),
|
||||||
|
/// ein `Creature` kann seine Attribute also gar nicht allein ausrechnen.
|
||||||
|
///
|
||||||
|
/// Vier Quellen: `base` → `options` → `equipment` samt Affixen → befristete
|
||||||
|
/// Modifikatoren. **Kein Cache** (Ticket 07): Das passiert pro Tastendruck,
|
||||||
|
/// nicht pro Frame.
|
||||||
|
pub fn attribute(
|
||||||
|
&self,
|
||||||
|
who: CreatureRef,
|
||||||
|
attr: Attribute,
|
||||||
|
_against: Option<CreatureRef>,
|
||||||
|
reg: &Registries,
|
||||||
|
) -> f32 {
|
||||||
|
let c = self.creature(who);
|
||||||
|
|
||||||
|
let from_options = c
|
||||||
|
.options
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| reg.options.get(id))
|
||||||
|
.flat_map(|o| o.modifiers.iter());
|
||||||
|
|
||||||
|
let from_equipment = c.equipment.values().flat_map(|inst| {
|
||||||
|
reg.items
|
||||||
|
.get(&inst.def)
|
||||||
|
.map(|i| i.modifiers.as_slice())
|
||||||
|
.unwrap_or(&[])
|
||||||
|
.iter()
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO(M3): Affix-Modifikatoren des Exemplars, befristete Modifikatoren
|
||||||
|
// am Traeger, und `_against` fuer bedingte Modifikatoren
|
||||||
|
// („+5 Schaden gegen Untote", Ticket 08).
|
||||||
|
attributes::stack(c.base.get(attr), from_options.chain(from_equipment), attr)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der **eine** Eingang, an dem ein Zug endet (Ticket 11).
|
||||||
|
pub fn execute(&mut self, cmd: Command, _reg: &Registries) -> Outcome {
|
||||||
|
let outcome = match cmd {
|
||||||
|
Command::Move(dir) => self.step(dir),
|
||||||
|
Command::Wait => {
|
||||||
|
self.last = String::from("gewartet");
|
||||||
|
Outcome::TurnPassed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nur ausgefuehrte Kommandos kosten einen Zug (Ticket 12).
|
||||||
|
if matches!(outcome, Outcome::TurnPassed) {
|
||||||
|
self.turn += 1;
|
||||||
|
// TODO(M3): befristete Modifikatoren ticken, Ausdauer regeneriert,
|
||||||
|
// Abklingzeiten sinken, `turn_started` feuern, dann die
|
||||||
|
// Event-Warteschlange leerarbeiten (Ticket 08/14).
|
||||||
|
}
|
||||||
|
outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
fn step(&mut self, dir: Direction) -> Outcome {
|
||||||
|
let (x, y) = self.player_pos;
|
||||||
|
let (nx, ny) = match dir {
|
||||||
|
Direction::North => (x, y.saturating_sub(1)),
|
||||||
|
Direction::South => (x, y + 1),
|
||||||
|
Direction::West => (x.saturating_sub(1), y),
|
||||||
|
Direction::East => (x + 1, y),
|
||||||
|
};
|
||||||
|
|
||||||
|
if self.floor.tile(nx, ny) == Tile::Wall {
|
||||||
|
return Outcome::Rejected("Da ist eine Wand");
|
||||||
|
}
|
||||||
|
|
||||||
|
self.player_pos = (nx, ny);
|
||||||
|
self.last = match self.floor.tile(nx, ny) {
|
||||||
|
Tile::Stairs => String::from("Eine Treppe führt nach unten"),
|
||||||
|
_ => format!("{},{}", nx, ny),
|
||||||
|
};
|
||||||
|
Outcome::TurnPassed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::data::load_mods;
|
||||||
|
|
||||||
|
fn zwergenkrieger() -> (Game, Registries) {
|
||||||
|
let (reg, _) = load_mods().expect("core muss laden");
|
||||||
|
let mut hero = Creature::new("Testheld");
|
||||||
|
hero.options = vec![String::from("core:dwarf"), String::from("core:warrior")];
|
||||||
|
hero.equipment
|
||||||
|
.insert(String::from("core:hand"), ItemInstance::new("core:iron_sword"));
|
||||||
|
(Game::new(hero), reg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Zielsatz von Meilenstein 1 als Behauptung: ein Wert, der aus einem
|
||||||
|
/// Modifikator stammt, der aus einer TOML-Datei kam.
|
||||||
|
#[test]
|
||||||
|
fn attribut_sammelt_aus_optionen_und_ausruestung() {
|
||||||
|
let (game, reg) = zwergenkrieger();
|
||||||
|
let p = CreatureRef::Player;
|
||||||
|
|
||||||
|
// Basis 1, Zwerg +2 (character_options.toml)
|
||||||
|
assert_eq!(game.attribute(p, Attribute::Constitution, None, ®), 3.0);
|
||||||
|
|
||||||
|
// Basis 2, Eisenschwert +5 flach (items.toml), Krieger +10 % ⇒ (2+5)*1.1
|
||||||
|
let damage = game.attribute(p, Attribute::Damage, None, ®);
|
||||||
|
assert!((damage - 7.7).abs() < 0.001, "erwartet 7.7, war {damage}");
|
||||||
|
|
||||||
|
// Unberuehrtes Attribut bleibt bei der Basis.
|
||||||
|
assert_eq!(game.attribute(p, Attribute::Charisma, None, ®), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ticket 07 hat `Registries` **neben** `Game` gelegt, weil ein Feld bei
|
||||||
|
/// jedem Methodenaufruf mit `&mut self` kollidiert waere. Dass dieser Test
|
||||||
|
/// kompiliert, *ist* der Beweis — Ticket 12 konnte ihn nicht fuehren, weil
|
||||||
|
/// der Durchstich keine Registries lud.
|
||||||
|
#[test]
|
||||||
|
fn disjunkte_borrows_ueber_methodenaufrufe_hinweg() {
|
||||||
|
let (mut game, reg) = zwergenkrieger();
|
||||||
|
|
||||||
|
game.execute(Command::Move(Direction::North), ®);
|
||||||
|
let _ = game.attribute(CreatureRef::Player, Attribute::Damage, None, ®);
|
||||||
|
game.execute(Command::Wait, ®);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Befund aus Ticket 12: „alle Commands kosten einen Zug" heisst **alle
|
||||||
|
/// ausgefuehrten**.
|
||||||
|
#[test]
|
||||||
|
fn wand_kostet_keinen_zug() {
|
||||||
|
let (mut game, reg) = zwergenkrieger();
|
||||||
|
game.player_pos = (1, 1);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
game.execute(Command::Move(Direction::North), ®),
|
||||||
|
Outcome::Rejected(_)
|
||||||
|
));
|
||||||
|
assert_eq!(game.turn, 0, "eine Wand darf keine Zeit verbrauchen");
|
||||||
|
|
||||||
|
assert!(matches!(game.execute(Command::Wait, ®), Outcome::TurnPassed));
|
||||||
|
assert_eq!(game.turn, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/items.rs
82
src/items.rs
@@ -1,82 +0,0 @@
|
|||||||
use serde::Deserialize;
|
|
||||||
use serde::de::DeserializeOwned;
|
|
||||||
|
|
||||||
pub mod armor;
|
|
||||||
pub mod weapon;
|
|
||||||
|
|
||||||
pub use armor::ItemArmor;
|
|
||||||
pub use weapon::ItemWeapon;
|
|
||||||
|
|
||||||
// === ENUMS ===
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub enum ItemType {
|
|
||||||
Weapon,
|
|
||||||
OffHand,
|
|
||||||
Helmet,
|
|
||||||
Chestplate,
|
|
||||||
Gloves,
|
|
||||||
Boots,
|
|
||||||
Consumable,
|
|
||||||
Currency,
|
|
||||||
}
|
|
||||||
|
|
||||||
// === STRUCTS ===
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct Item {
|
|
||||||
pub id: String,
|
|
||||||
pub name: String,
|
|
||||||
pub value: u32,
|
|
||||||
pub item_type: ItemType,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rohform einer Item-Datei. Jede TOML nutzt `[[items]]` als Wurzel,
|
|
||||||
/// dadurch bedient dieser eine Wrapper alle Dateien.
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct ItemFile<T> {
|
|
||||||
items: Vec<T>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Laufzeit-Registry: einmal beim Start laden, danach als `&ItemCatalog` durchreichen.
|
|
||||||
// TODO: OffHand, Consumable und Currency haben noch keine Structs/TOMLs — hier ergaenzen.
|
|
||||||
pub struct ItemCatalog {
|
|
||||||
pub weapons: Vec<ItemWeapon>,
|
|
||||||
pub armors: Vec<ItemArmor>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// === FUNCTIONS ===
|
|
||||||
pub fn load_items() -> ItemCatalog {
|
|
||||||
ItemCatalog {
|
|
||||||
weapons: parse(include_str!("items/weapons.toml"), "weapons.toml"),
|
|
||||||
armors: parse(include_str!("items/armors.toml"), "armors.toml"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse<T: DeserializeOwned>(raw: &str, file: &str) -> Vec<T> {
|
|
||||||
let parsed: ItemFile<T> =
|
|
||||||
toml::from_str(raw).unwrap_or_else(|e| panic!("{} ist kaputt:\n{}", file, e));
|
|
||||||
parsed.items
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rahmen um beliebig viele Zeilen. Wird von jedem `print_values` genutzt.
|
|
||||||
pub(crate) fn print_box(lines: &[String]) {
|
|
||||||
let longest: usize = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0);
|
|
||||||
let border: String = format!("+-{}-+", "-".repeat(longest));
|
|
||||||
|
|
||||||
println!("{}", border);
|
|
||||||
for line in lines {
|
|
||||||
println!("| {}{} |", line, " ".repeat(longest - line.chars().count()));
|
|
||||||
}
|
|
||||||
println!("{}", border);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn item_files_parse() {
|
|
||||||
let catalog: ItemCatalog = load_items();
|
|
||||||
assert!(!catalog.weapons.is_empty());
|
|
||||||
assert!(!catalog.armors.is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use super::{Item, print_box};
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct ItemArmor {
|
|
||||||
pub armor: u32,
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub item: Item,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ItemArmor {
|
|
||||||
pub fn print_values(&self) {
|
|
||||||
print_box(&[
|
|
||||||
format!("Stats for {} ", self.item.name),
|
|
||||||
format!("ID: {}", self.item.id),
|
|
||||||
format!("Slot: {:?}", self.item.item_type),
|
|
||||||
format!("Armor: {}", self.armor),
|
|
||||||
format!("Value: {}", self.item.value),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
[[items]]
|
|
||||||
id = "iron_helmet"
|
|
||||||
name = "Iron Helmet"
|
|
||||||
armor = 4
|
|
||||||
value = 6
|
|
||||||
item_type = "Helmet"
|
|
||||||
|
|
||||||
[[items]]
|
|
||||||
id = "leather_boots"
|
|
||||||
name = "Leather Boots"
|
|
||||||
armor = 2
|
|
||||||
value = 3
|
|
||||||
item_type = "Boots"
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use super::{Item, print_box};
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct ItemWeapon {
|
|
||||||
pub base_damage: u32,
|
|
||||||
pub critical_factor: f32,
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub item: Item,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ItemWeapon {
|
|
||||||
pub fn print_values(&self) {
|
|
||||||
let crit_damage: u32 = (self.base_damage as f32 * self.critical_factor).ceil() as u32;
|
|
||||||
let crit_percent: i32 = ((self.critical_factor - 1.0) * 100.0).round() as i32;
|
|
||||||
|
|
||||||
print_box(&[
|
|
||||||
format!("Stats for {} ", self.item.name),
|
|
||||||
format!("ID: {}", self.item.id),
|
|
||||||
format!("Base Damage: {} ({})", self.base_damage, crit_damage),
|
|
||||||
format!("Critical Factor: {}%", crit_percent),
|
|
||||||
format!("Value: {}", self.item.value),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
[[items]]
|
|
||||||
id = "iron_sword"
|
|
||||||
name = "Iron Sword"
|
|
||||||
base_damage = 10
|
|
||||||
critical_factor = 1.1
|
|
||||||
value = 5
|
|
||||||
item_type = "Weapon"
|
|
||||||
|
|
||||||
[[items]]
|
|
||||||
id = "iron_dagger"
|
|
||||||
name = "Iron Dagger"
|
|
||||||
base_damage = 8
|
|
||||||
critical_factor = 1.5
|
|
||||||
value = 3
|
|
||||||
item_type = "Weapon"
|
|
||||||
58
src/main.rs
58
src/main.rs
@@ -1,20 +1,50 @@
|
|||||||
mod items;
|
//! rustkeep — Meilenstein 1: vertikaler Durchstich.
|
||||||
mod player;
|
//!
|
||||||
mod stats;
|
//! Ziel: Der Held laeuft in einem handgebauten Testraum herum, oeffnet den
|
||||||
|
//! Charakterbogen und sieht dort einen Wert, der aus einem Modifikator stammt,
|
||||||
|
//! der aus einer TOML-Datei kam.
|
||||||
|
//!
|
||||||
|
//! Damit stehen die Zusagen auf dem Pruefstand, die bisher nur auf Papier
|
||||||
|
//! standen: `Registries` neben `Game` (07), zweistufige Deserialisierung (04),
|
||||||
|
//! `Game::attribute` ueber mehrere Quellen (07/08), `game/` bleibt ratatui-frei
|
||||||
|
//! (11), Press-Filter unter Windows (06/12).
|
||||||
|
|
||||||
use items::*;
|
mod data;
|
||||||
use player::*;
|
mod game;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use game::{Creature, ItemInstance};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let catalog: ItemCatalog = load_items();
|
// Die `Result`-Kette endet **vor** `main` (Ticket 04): `main() -> Result<…>`
|
||||||
|
// druckt ueber `Debug` und setzt dem Spieler `LoadError { .. }` vor.
|
||||||
for weapon in &catalog.weapons {
|
let (registries, warnings) = match data::load_mods() {
|
||||||
weapon.print_values();
|
Ok(v) => v,
|
||||||
}
|
Err(e) => {
|
||||||
for armor in &catalog.armors {
|
eprintln!("{e}");
|
||||||
armor.print_values();
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let player: Player = Player::new("Reya".to_string());
|
let log_name = data::write_log(&warnings);
|
||||||
player.show_stats();
|
let game = game::Game::new(starting_hero());
|
||||||
|
|
||||||
|
if let Err(e) = ui::run(game, registries, warnings.len(), log_name) {
|
||||||
|
eprintln!("Terminal-Fehler: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vorgefertigter Held, damit der Durchstich etwas zu zeigen hat.
|
||||||
|
///
|
||||||
|
// 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.
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
use crate::items::print_box;
|
|
||||||
use crate::stats::Stats;
|
|
||||||
|
|
||||||
pub struct Player {
|
|
||||||
pub name: String,
|
|
||||||
pub stats: Stats,
|
|
||||||
pub next_level_exp: usize,
|
|
||||||
pub current_exp: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Player {
|
|
||||||
pub fn new(name: String) -> Self {
|
|
||||||
Self {
|
|
||||||
name,
|
|
||||||
stats: Stats::new(),
|
|
||||||
next_level_exp: 10,
|
|
||||||
current_exp: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn show_stats(&self) {
|
|
||||||
let stats: &Stats = &self.stats;
|
|
||||||
|
|
||||||
print_box(&[
|
|
||||||
format!("Name: {}", self.name),
|
|
||||||
format!(
|
|
||||||
"Level: {} ({}/{})",
|
|
||||||
stats.level, self.current_exp, self.next_level_exp
|
|
||||||
),
|
|
||||||
format!("Strength: {}", stats.strength),
|
|
||||||
format!("Dexterity: {}", stats.dexterity),
|
|
||||||
format!("Constitution: {}", stats.constitution),
|
|
||||||
format!("Charisma: {}", stats.charisma),
|
|
||||||
format!("Luck: {}", stats.luck),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
68
src/stats.rs
68
src/stats.rs
@@ -1,68 +0,0 @@
|
|||||||
pub struct Stats {
|
|
||||||
pub max_health: f32,
|
|
||||||
pub current_health: f32,
|
|
||||||
pub max_stamina: f32,
|
|
||||||
pub current_stamina: f32,
|
|
||||||
pub strength: usize, // Determines base_damage modifier
|
|
||||||
pub dexterity: usize, // Determines Critical Factor Modifier
|
|
||||||
pub constitution: usize, // Determines Max Health Bonuses
|
|
||||||
pub charisma: usize, // Determines Shop Prices
|
|
||||||
pub luck: usize, // Determines Loot Drop & Critical Chance
|
|
||||||
pub level: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Stats {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
let health: f32 = 100.0;
|
|
||||||
let stamina: f32 = 100.0;
|
|
||||||
Self {
|
|
||||||
max_health: health,
|
|
||||||
current_health: health,
|
|
||||||
max_stamina: stamina,
|
|
||||||
current_stamina: stamina,
|
|
||||||
strength: 1,
|
|
||||||
dexterity: 1,
|
|
||||||
constitution: 1,
|
|
||||||
charisma: 1,
|
|
||||||
luck: 1,
|
|
||||||
level: 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delta auf die aktuelle Health: positiv heilt, negativ verletzt.
|
|
||||||
pub fn change_health(&mut self, delta: f32) {
|
|
||||||
self.current_health = (self.current_health + delta).clamp(0.0, self.max_health);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delta auf die aktuelle Stamina: positiv regeneriert, negativ verbraucht.
|
|
||||||
// TODO: noch ungenutzt — wird vom Kampfsystem aufgerufen, sobald es existiert.
|
|
||||||
pub fn change_stamina(&mut self, delta: f32) {
|
|
||||||
self.current_stamina = (self.current_stamina + delta).clamp(0.0, self.max_stamina);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_dead(&self) -> bool {
|
|
||||||
self.current_health <= 0.0
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: noch ungenutzt — wird vom Exp-/Level-System aufgerufen, sobald es existiert.
|
|
||||||
pub fn level_up(&mut self) {
|
|
||||||
self.level = (self.level + 1).clamp(1, 50);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn health_clamps_at_both_ends() {
|
|
||||||
let mut stats: Stats = Stats::new();
|
|
||||||
|
|
||||||
stats.change_health(50.0);
|
|
||||||
assert_eq!(stats.current_health, stats.max_health);
|
|
||||||
|
|
||||||
stats.change_health(-500.0);
|
|
||||||
assert_eq!(stats.current_health, 0.0);
|
|
||||||
assert!(stats.is_dead());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
205
src/ui/app.rs
Normal file
205
src/ui/app.rs
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
//! Die Schleife. Blockierendes `event::read()`, kein Tick (Ticket 06).
|
||||||
|
|
||||||
|
use ratatui::Frame;
|
||||||
|
use ratatui::crossterm::event::{self, Event, KeyEventKind};
|
||||||
|
use ratatui::layout::{Constraint, Layout, Rect};
|
||||||
|
use ratatui::style::{Color, Style, Stylize};
|
||||||
|
use ratatui::text::Line;
|
||||||
|
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::game::{Attribute, CreatureRef, Game, Outcome};
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
/// `game` und `registries` sind **getrennte Felder**, damit die disjunkten
|
||||||
|
/// Borrows aus Ticket 07 funktionieren.
|
||||||
|
pub game: Game,
|
||||||
|
pub registries: Registries,
|
||||||
|
screens: Vec<Screen>,
|
||||||
|
warnings: usize,
|
||||||
|
log_name: Option<String>,
|
||||||
|
rejected: Option<&'static str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(game: Game, registries: Registries, warnings: usize, log_name: Option<String>) -> std::io::Result<()> {
|
||||||
|
let mut app = App {
|
||||||
|
game,
|
||||||
|
registries,
|
||||||
|
screens: vec![Screen::MainMenu],
|
||||||
|
warnings,
|
||||||
|
log_name,
|
||||||
|
rejected: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
ratatui::run(|terminal| {
|
||||||
|
loop {
|
||||||
|
terminal.draw(|frame| draw(frame, &app))?;
|
||||||
|
|
||||||
|
let Event::Key(key) = event::read()? else { continue };
|
||||||
|
// Pflichtfilter unter Windows: Ticket 12 hat gemessen, dass jeder
|
||||||
|
// Tastendruck zwei Events erzeugt (Press und Release).
|
||||||
|
if key.kind != KeyEventKind::Press {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(screen) = app.screens.last_mut() else { break Ok(()) };
|
||||||
|
|
||||||
|
match screen.handle_key(key.code) {
|
||||||
|
Response::None => {}
|
||||||
|
Response::Pop => {
|
||||||
|
app.screens.pop();
|
||||||
|
if app.screens.is_empty() {
|
||||||
|
break Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Response::Push(s) => app.screens.push(s),
|
||||||
|
Response::Command(cmd) => {
|
||||||
|
// 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) {
|
||||||
|
Outcome::TurnPassed => None,
|
||||||
|
Outcome::Rejected(reason) => Some(reason),
|
||||||
|
};
|
||||||
|
while app.screens.last().is_some_and(Screen::is_overlay) {
|
||||||
|
app.screens.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Response::Quit => break Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(frame: &mut Frame, app: &App) {
|
||||||
|
let area = frame.area();
|
||||||
|
let first = app.screens.iter().rposition(|s| !s.is_overlay()).unwrap_or(0);
|
||||||
|
|
||||||
|
for screen in &app.screens[first..] {
|
||||||
|
match screen {
|
||||||
|
Screen::MainMenu => draw_main_menu(frame, area, app),
|
||||||
|
Screen::Explore => draw_explore(frame, area, app),
|
||||||
|
Screen::CharacterSheet => draw_sheet(frame, area, app),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_main_menu(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
|
let mut text = vec![
|
||||||
|
Line::from("rustkeep".bold()),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from("Enter Dungeon betreten"),
|
||||||
|
Line::from("q beenden"),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Eine Zeile, kein Screen — der Inhalt gehoert in einen Texteditor
|
||||||
|
// (Ticket 04).
|
||||||
|
if app.warnings > 0 {
|
||||||
|
let note = match &app.log_name {
|
||||||
|
Some(file) => format!("{} Ladewarnungen — logs/{file}", app.warnings),
|
||||||
|
None => format!("{} Ladewarnungen (Log nicht schreibbar)", app.warnings),
|
||||||
|
};
|
||||||
|
text.push(Line::from(""));
|
||||||
|
text.push(Line::from(note).style(Style::new().fg(Color::Yellow)));
|
||||||
|
}
|
||||||
|
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(text).block(Block::bordered().title(" Hauptmenü ")),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_explore(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
|
let block = Block::bordered().title(format!(" Etage {} ", app.game.floor.number));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
|
let [grid, bar] = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(inner);
|
||||||
|
frame.render_widget(
|
||||||
|
GridWidget { floor: &app.game.floor, player: app.game.player_pos },
|
||||||
|
grid,
|
||||||
|
);
|
||||||
|
|
||||||
|
let (note, color) = match app.rejected {
|
||||||
|
Some(reason) => (reason.to_string(), Color::Red),
|
||||||
|
None => (app.game.last.clone(), Color::Cyan),
|
||||||
|
};
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(format!(
|
||||||
|
"Zug {} │ {} │ c Charakterbogen │ {}",
|
||||||
|
app.game.turn, app.game.player.name, note
|
||||||
|
))
|
||||||
|
.right_aligned()
|
||||||
|
.style(Style::new().fg(color)),
|
||||||
|
bar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Charakterbogen ist eine **Schleife ueber Registries**, kein gemauertes
|
||||||
|
/// Layout — Modder koennen Slots und Wahlkategorien ergaenzen (Ticket 02).
|
||||||
|
fn draw_sheet(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
|
let block = Block::bordered().title(" Charakterbogen ");
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
|
let [left, right] = Layout::horizontal([Constraint::Percentage(50); 2]).areas(inner);
|
||||||
|
|
||||||
|
// Attribute: jeder Wert kommt aus Game::attribute, laeuft also durch alle
|
||||||
|
// Quellen — das ist der Punkt des ganzen Durchstichs.
|
||||||
|
let rows: Vec<Row> = Attribute::ALL
|
||||||
|
.iter()
|
||||||
|
.map(|a| {
|
||||||
|
let base = app.game.player.base.get(*a);
|
||||||
|
let total =
|
||||||
|
app.game
|
||||||
|
.attribute(CreatureRef::Player, *a, None, &app.registries);
|
||||||
|
let cell = if (total - base).abs() < f32::EPSILON {
|
||||||
|
format!("{total:.0}")
|
||||||
|
} else {
|
||||||
|
format!("{total:.0} ({base:.0} Basis)")
|
||||||
|
};
|
||||||
|
Row::new(vec![a.name().to_string(), cell])
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
frame.render_widget(
|
||||||
|
Table::new(rows, [Constraint::Length(20), Constraint::Min(0)])
|
||||||
|
.block(Block::bordered().title(" Attribute ")),
|
||||||
|
left,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
|
lines.push(Line::from("Herkunft".bold()));
|
||||||
|
for id in &app.game.player.options {
|
||||||
|
let name = app.registries.options.get(id).map(|o| o.name.as_str()).unwrap_or(id);
|
||||||
|
lines.push(Line::from(format!(" {name}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
lines.push(Line::from("Ausrüstung".bold()));
|
||||||
|
for slot in app.registries.slots_ordered() {
|
||||||
|
let worn = app.game.player.equipment.get(&slot.id).and_then(|inst| {
|
||||||
|
app.registries.items.get(&inst.def).map(|i| (i.name.as_str(), i.color.as_deref()))
|
||||||
|
});
|
||||||
|
match worn {
|
||||||
|
Some((name, color)) => lines.push(Line::from(vec![
|
||||||
|
format!(" {:<10} ", slot.name).into(),
|
||||||
|
name.to_string().fg(theme::color(color)),
|
||||||
|
])),
|
||||||
|
None => lines.push(
|
||||||
|
Line::from(format!(" {:<10} —", slot.name)).style(Style::new().fg(Color::DarkGray)),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
lines.push(Line::from("Esc zurück").style(Style::new().fg(Color::DarkGray)));
|
||||||
|
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(lines).block(Block::bordered().title(" Herkunft & Ausrüstung ")),
|
||||||
|
right,
|
||||||
|
);
|
||||||
|
}
|
||||||
44
src/ui/grid.rs
Normal file
44
src/ui/grid.rs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
//! Dungeon-Grid als eigenes Widget. Ticket 06 hat festgestellt, dass es weder
|
||||||
|
//! mitgeliefert noch als Drittanbieter-Crate existiert — also Eigenbau mit
|
||||||
|
//! direktem Buffer-Zugriff. Der Durchstich in Ticket 12 hat das bestaetigt.
|
||||||
|
|
||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::widgets::Widget;
|
||||||
|
|
||||||
|
use crate::game::{Floor, Tile};
|
||||||
|
|
||||||
|
pub struct GridWidget<'a> {
|
||||||
|
pub floor: &'a Floor,
|
||||||
|
pub player: (u16, u16),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for GridWidget<'_> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
for y in 0..self.floor.height {
|
||||||
|
for x in 0..self.floor.width {
|
||||||
|
let (px, py) = (area.x + x, area.y + y);
|
||||||
|
if px >= area.right() || py >= area.bottom() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (symbol, color) = match self.floor.tile(x, y) {
|
||||||
|
Tile::Wall => ("█", Color::DarkGray),
|
||||||
|
Tile::Floor => ("·", Color::Rgb(60, 60, 70)),
|
||||||
|
Tile::Door => ("+", Color::Yellow),
|
||||||
|
Tile::Stairs => (">", Color::Green),
|
||||||
|
Tile::Entrance => ("<", Color::Blue),
|
||||||
|
};
|
||||||
|
buf[(px, py)].set_symbol(symbol).set_style(Style::new().fg(color));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (sx, sy) = (area.x + self.player.0, area.y + self.player.1);
|
||||||
|
if sx < area.right() && sy < area.bottom() {
|
||||||
|
buf[(sx, sy)]
|
||||||
|
.set_symbol("@")
|
||||||
|
.set_style(Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/ui/mod.rs
Normal file
8
src/ui/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
//! Alles, was ratatui kennen darf.
|
||||||
|
|
||||||
|
mod app;
|
||||||
|
mod grid;
|
||||||
|
mod screen;
|
||||||
|
mod theme;
|
||||||
|
|
||||||
|
pub use app::run;
|
||||||
73
src/ui/screen.rs
Normal file
73
src/ui/screen.rs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
//! Screen-Stack und Antwort — Ticket 11, im Durchstich (12) bestaetigt.
|
||||||
|
|
||||||
|
use ratatui::crossterm::event::KeyCode;
|
||||||
|
|
||||||
|
use crate::game::{Command, Direction};
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
pub enum Screen {
|
||||||
|
MainMenu,
|
||||||
|
Explore,
|
||||||
|
CharacterSheet,
|
||||||
|
// TODO(M2+): `CharacterCreation`, `Combat`, `Inventory`, `QuestLog`,
|
||||||
|
// `Shop`, `Dialog`, `Confirm`, `GameOver` — elf sind es am Ende
|
||||||
|
// (Ticket 11/14).
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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/`.
|
||||||
|
pub enum Response {
|
||||||
|
None,
|
||||||
|
Pop,
|
||||||
|
Push(Screen),
|
||||||
|
Command(Command),
|
||||||
|
Quit,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Screen {
|
||||||
|
pub fn is_overlay(&self) -> bool {
|
||||||
|
// TODO(M2): `Confirm` ist das erste echte Overlay. Bis dahin haelt die
|
||||||
|
// Regel „ein Command schliesst alle Overlays" nur die Luft.
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_key(&mut self, key: KeyCode) -> Response {
|
||||||
|
match self {
|
||||||
|
Screen::MainMenu => match key {
|
||||||
|
KeyCode::Enter => Response::Push(Screen::Explore),
|
||||||
|
KeyCode::Char('q') | KeyCode::Esc => Response::Quit,
|
||||||
|
_ => Response::None,
|
||||||
|
},
|
||||||
|
|
||||||
|
Screen::Explore => match key {
|
||||||
|
// Pfeiltasten *und* hjkl — drei zusaetzliche Arme sind die
|
||||||
|
// billige Hoeflichkeit gegenueber Vim-Fingern (Ticket 11).
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
|
Response::Command(Command::Move(Direction::North))
|
||||||
|
}
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
|
Response::Command(Command::Move(Direction::South))
|
||||||
|
}
|
||||||
|
KeyCode::Left | KeyCode::Char('h') => {
|
||||||
|
Response::Command(Command::Move(Direction::West))
|
||||||
|
}
|
||||||
|
KeyCode::Right | KeyCode::Char('l') => {
|
||||||
|
Response::Command(Command::Move(Direction::East))
|
||||||
|
}
|
||||||
|
KeyCode::Char('.') => Response::Command(Command::Wait),
|
||||||
|
// Screen-Navigation ist reine UI und fasst `Game` nie an.
|
||||||
|
KeyCode::Char('c') => Response::Push(Screen::CharacterSheet),
|
||||||
|
KeyCode::Esc => Response::Pop,
|
||||||
|
_ => Response::None,
|
||||||
|
},
|
||||||
|
|
||||||
|
Screen::CharacterSheet => match key {
|
||||||
|
KeyCode::Esc | KeyCode::Char('c') | KeyCode::Char('q') => Response::Pop,
|
||||||
|
_ => Response::None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/ui/theme.rs
Normal file
26
src/ui/theme.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
//! Die einzige Stelle, an der aus einem Farb*namen* eine ratatui-Farbe wird.
|
||||||
|
//!
|
||||||
|
//! Ticket 11 hat `symbol` als Schluessel verworfen, weil `ui/theme.rs` **Code**
|
||||||
|
//! ist: Ein Modder, der `meinmod:frostbite` anlegt, koennte dort nichts
|
||||||
|
//! eintragen. Deshalb tragen die TOML-Daten Zeichen und Farbnamen selbst, und
|
||||||
|
//! hier steht nur die Uebersetzung.
|
||||||
|
|
||||||
|
use ratatui::style::Color;
|
||||||
|
|
||||||
|
pub fn color(name: Option<&str>) -> Color {
|
||||||
|
match name.unwrap_or_default() {
|
||||||
|
"black" => Color::Black,
|
||||||
|
"red" => Color::Red,
|
||||||
|
"green" => Color::Green,
|
||||||
|
"yellow" => Color::Yellow,
|
||||||
|
"blue" => Color::Blue,
|
||||||
|
"magenta" => Color::Magenta,
|
||||||
|
"cyan" => Color::Cyan,
|
||||||
|
"white" => Color::White,
|
||||||
|
"gray" | "grey" => Color::Gray,
|
||||||
|
"dark_gray" | "dark_grey" => Color::DarkGray,
|
||||||
|
// Unbekannter Name ist kein Fehler, sondern Standardfarbe — ein Tippfehler
|
||||||
|
// im Mod soll das Spiel nicht anhalten (Ticket 04).
|
||||||
|
_ => Color::Reset,
|
||||||
|
}
|
||||||
|
}
|
||||||
51
tests/boundaries.rs
Normal file
51
tests/boundaries.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
//! Ticket 11: `game/` kennt ratatui nicht — und zwar mechanisch geprueft.
|
||||||
|
//!
|
||||||
|
//! Ein Cargo-Workspace mit `game` als eigener Crate waere echte
|
||||||
|
//! Compiler-Durchsetzung und ist die Decke, falls es je ernst wird. Bis dahin
|
||||||
|
//! faengt dieser Test denselben Fehler und kostet keine Umstrukturierung.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
fn rust_files(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
|
||||||
|
for entry in fs::read_dir(dir).expect("Verzeichnis lesbar").flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
rust_files(&path, out);
|
||||||
|
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||||
|
out.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_bleibt_ratatui_frei() {
|
||||||
|
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
|
||||||
|
for dir in ["src/game", "src/data"] {
|
||||||
|
let mut files = Vec::new();
|
||||||
|
rust_files(&root.join(dir), &mut files);
|
||||||
|
assert!(!files.is_empty(), "{dir} sollte Dateien enthalten");
|
||||||
|
|
||||||
|
for file in files {
|
||||||
|
let source = fs::read_to_string(&file).expect("Datei lesbar");
|
||||||
|
|
||||||
|
// Kommentare ausnehmen. Ticket 11 hat „liest die Dateien und prueft
|
||||||
|
// auf ratatui" gesagt — die woertliche Fassung schlaegt an, sobald
|
||||||
|
// ein Doc-Kommentar erklaert, dass die Grenze existiert.
|
||||||
|
let offender = source
|
||||||
|
.lines()
|
||||||
|
.enumerate()
|
||||||
|
.find(|(_, l)| !l.trim_start().starts_with("//") && l.contains("ratatui"));
|
||||||
|
|
||||||
|
if let Some((n, line)) = offender {
|
||||||
|
panic!(
|
||||||
|
"{}:{}: „{}\" — die Grenze aus Ticket 11 ist gefallen",
|
||||||
|
file.display(),
|
||||||
|
n + 1,
|
||||||
|
line.trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user