feat: m3 kampf, events und trigger
Der Dungeon hat jetzt Gegner, und man kann sterben.
- game/action.rs: sieben Schadenstypen, fuenf der zehn Aktionen, Target
- game/condition.rs: Bedingungsgrammatik (Liste = UND, not je Eintrag)
- game/event.rs: zwoelf Events, Trigger als vierte Wirkungsform
- game/combat.rs: Treffer- und Schadensformel mit drei Klemmbereichen
- Game::apply, drain_events (FIFO), Initiative, Rundenablauf aus Ticket 14
- data/: abilities-, creatures- und modifiers-Registry, [balance] feldweise
- ui/: Combat-Screen mit Faehigkeitsmenue und Sperrgrund
Drei Befunde:
- Die Trefferformel war toter Code — jeder Angriff traf. Nur der Compiler
hat es gemeldet ('hit_chance is never used'). Test haelt sie am Leben.
- Die Warteschlange war ein Stack (pop statt pop_front), obwohl Ticket 08
ausdruecklich 'haengt es hinten an' sagt. Jetzt VecDeque.
- Benannte Modifikatoren (core:poisoned) hatten keine Registry — Ticket 01
laesst apply_modifier per ID referenzieren, aber keine der zwoelf
definierte sie. Das ist die dreizehnte.
43 Tests.
This commit is contained in:
58
mods/core/abilities.toml
Normal file
58
mods/core/abilities.toml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Eine Faehigkeit ist ein benanntes, wiederholt ausloesbares ANGEBOT an den
|
||||||
|
# Spieler, das Aktionen ausfuehrt (Ticket 02) — kein Gegenstand im Inventar,
|
||||||
|
# sonst koennte ein Magier seine Zauber verkaufen.
|
||||||
|
#
|
||||||
|
# Angriff und Flucht sind bewusst keine Engine-Sonderfaelle, sondern gewoehnliche
|
||||||
|
# Faehigkeiten (Ticket 11/14). Ein Mod kann beide ersetzen.
|
||||||
|
#
|
||||||
|
# Menge = amount + scale * damage-Attribut(Verursacher) — eine Formel fuer
|
||||||
|
# Basisangriff, Feuerball und Wuchtschlag (Nachtrag aus Ticket 14).
|
||||||
|
|
||||||
|
[[abilities]]
|
||||||
|
id = "attack"
|
||||||
|
name = "Angriff"
|
||||||
|
cost = 0
|
||||||
|
actions = [ { action = "damage", scale = 1.0, damage_type = "slash", target = "target" } ]
|
||||||
|
|
||||||
|
[[abilities]]
|
||||||
|
id = "flee"
|
||||||
|
name = "Fliehen"
|
||||||
|
cost = 10
|
||||||
|
actions = [ { action = "flee" } ]
|
||||||
|
|
||||||
|
[[abilities]]
|
||||||
|
id = "power_strike"
|
||||||
|
name = "Wuchtschlag"
|
||||||
|
description = "Holt weit aus. Kostet Luft."
|
||||||
|
cost = 15
|
||||||
|
cooldown = 3
|
||||||
|
actions = [ { action = "damage", scale = 1.5, damage_type = "blunt", target = "target" } ]
|
||||||
|
|
||||||
|
[[abilities]]
|
||||||
|
id = "bone_throw"
|
||||||
|
name = "Knochenwurf"
|
||||||
|
cost = 8
|
||||||
|
cooldown = 2
|
||||||
|
actions = [ { action = "damage", amount = 6, damage_type = "blunt", target = "target" } ]
|
||||||
|
|
||||||
|
[[abilities]]
|
||||||
|
id = "rat_bite"
|
||||||
|
name = "Biss"
|
||||||
|
cost = 0
|
||||||
|
actions = [ { action = "damage", scale = 1.0, damage_type = "slash", target = "target" },
|
||||||
|
{ action = "apply_modifier", modifier = "core:poisoned", target = "target" } ]
|
||||||
|
|
||||||
|
[[abilities]]
|
||||||
|
id = "ghoul_claw"
|
||||||
|
name = "Klauenhieb"
|
||||||
|
cost = 0
|
||||||
|
actions = [ { action = "damage", scale = 1.2, damage_type = "slash", target = "target" } ]
|
||||||
|
|
||||||
|
[[abilities]]
|
||||||
|
id = "second_wind"
|
||||||
|
name = "Zweiter Atem"
|
||||||
|
description = "Sammelt sich, wenn es eng wird."
|
||||||
|
cost = 0
|
||||||
|
cooldown = 8
|
||||||
|
condition = [ { check = "health_below", who = "self", value = 0.4 } ]
|
||||||
|
actions = [ { action = "change_attribute", attribute = "health", amount = 25, target = "self" } ]
|
||||||
20
mods/core/balance.toml
Normal file
20
mods/core/balance.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Die Stellknoepfe aus Ticket 14 und 16.
|
||||||
|
#
|
||||||
|
# `[balance]` ist KEINE Registry: ein einzelner Block mit festem Schema, der
|
||||||
|
# FELDWEISE verschmilzt statt als ganzer Eintrag ersetzt zu werden — die
|
||||||
|
# ausdrueckliche Ausnahme zur Ganz-oder-gar-nicht-Regel aus Ticket 03.
|
||||||
|
#
|
||||||
|
# Die Begruendung bleibt eng: Das Schema ist der Engine bekannt. Genau das
|
||||||
|
# fehlte bei Inhalt mit offenen Feldern, und das war Ticket 03s Argument gegen
|
||||||
|
# feldweises Patchen.
|
||||||
|
#
|
||||||
|
# Ein Mod, der einen Knopf dreht, schreibt EINE Zeile. Standardwerte liegen in
|
||||||
|
# der Engine — was hier fehlt, gilt trotzdem.
|
||||||
|
|
||||||
|
[balance]
|
||||||
|
ausweich = 0.02 # wie stark dexterity des Ziels die Trefferchance senkt
|
||||||
|
flucht = 0.05 # wie stark der Geschicklichkeitsunterschied die Flucht traegt
|
||||||
|
stamina_regen = 4.0 # pro Zug — gibt `warten` seinen Zweck
|
||||||
|
resistance_cap = 0.8 # 1.0 waere Immunitaet, und das ist ein Spielabbruch
|
||||||
|
hit_floor = 0.05
|
||||||
|
hit_ceiling = 0.95
|
||||||
63
mods/core/creatures.toml
Normal file
63
mods/core/creatures.toml
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Gegner. Die Luecke lag seit Ticket 02 offen — `Creature` war der Laufzeittyp,
|
||||||
|
# woher seine Werte kommen, hat nie jemand gesagt (Ticket 14).
|
||||||
|
#
|
||||||
|
# `ai` ist eine GEORDNETE Liste: der erste passende UND verfuegbare Eintrag
|
||||||
|
# gewinnt. Ein Eintrag ohne `condition` greift immer und gehoert ans Ende —
|
||||||
|
# fehlt er, setzt der Gegner gelegentlich aus.
|
||||||
|
|
||||||
|
[[creatures]]
|
||||||
|
id = "rat"
|
||||||
|
name = "Riesenratte"
|
||||||
|
symbol = "r"
|
||||||
|
color = "yellow"
|
||||||
|
tags = ["core:beast"]
|
||||||
|
base = { max_health = 14, damage = 3, hit_chance = 0.65, dexterity = 4, critical_chance = 5 }
|
||||||
|
abilities = ["core:rat_bite"]
|
||||||
|
ai = [ { ability = "core:rat_bite" } ]
|
||||||
|
exp_reward = 4
|
||||||
|
|
||||||
|
[[creatures]]
|
||||||
|
id = "skeleton"
|
||||||
|
name = "Skelett"
|
||||||
|
symbol = "s"
|
||||||
|
color = "white"
|
||||||
|
tags = ["core:undead"]
|
||||||
|
base = { max_health = 26, damage = 6, hit_chance = 0.7, dexterity = 3, critical_chance = 5 }
|
||||||
|
abilities = ["core:attack", "core:bone_throw"]
|
||||||
|
ai = [
|
||||||
|
{ condition = [ { check = "health_below", who = "self", value = 0.35 } ],
|
||||||
|
ability = "core:bone_throw" },
|
||||||
|
{ ability = "core:attack" },
|
||||||
|
]
|
||||||
|
exp_reward = 9
|
||||||
|
|
||||||
|
[[creatures]]
|
||||||
|
id = "ghoul"
|
||||||
|
name = "Ghul"
|
||||||
|
symbol = "G"
|
||||||
|
color = "magenta"
|
||||||
|
tags = ["core:undead"]
|
||||||
|
base = { max_health = 40, damage = 9, hit_chance = 0.72, dexterity = 5, critical_chance = 10 }
|
||||||
|
abilities = ["core:ghoul_claw", "core:second_wind"]
|
||||||
|
ai = [
|
||||||
|
{ condition = [ { check = "health_below", who = "self", value = 0.4 } ],
|
||||||
|
ability = "core:second_wind" },
|
||||||
|
{ ability = "core:ghoul_claw" },
|
||||||
|
]
|
||||||
|
exp_reward = 18
|
||||||
|
|
||||||
|
[[creatures]]
|
||||||
|
id = "bone_lord"
|
||||||
|
name = "Knochenfürst"
|
||||||
|
symbol = "K"
|
||||||
|
color = "red"
|
||||||
|
tags = ["core:undead", "core:boss"]
|
||||||
|
base = { max_health = 90, damage = 14, hit_chance = 0.78, dexterity = 6, critical_chance = 12, critical_factor = 2.0 }
|
||||||
|
abilities = ["core:attack", "core:power_strike", "core:second_wind"]
|
||||||
|
ai = [
|
||||||
|
{ condition = [ { check = "health_below", who = "self", value = 0.3 } ],
|
||||||
|
ability = "core:second_wind" },
|
||||||
|
{ ability = "core:power_strike" },
|
||||||
|
{ ability = "core:attack" },
|
||||||
|
]
|
||||||
|
exp_reward = 60
|
||||||
@@ -15,6 +15,7 @@ size = [72, 34]
|
|||||||
rooms = 7
|
rooms = 7
|
||||||
required = ["core:entrance", "core:stairs", "core:merchant"]
|
required = ["core:entrance", "core:stairs", "core:merchant"]
|
||||||
prefabs = [ { tag = "core:normal", weight = 10 } ]
|
prefabs = [ { tag = "core:normal", weight = 10 } ]
|
||||||
|
creatures = [ { id = "core:rat", weight = 10 } ]
|
||||||
|
|
||||||
[[floors]]
|
[[floors]]
|
||||||
id = "floor_2"
|
id = "floor_2"
|
||||||
@@ -24,6 +25,7 @@ rooms = 8
|
|||||||
required = ["core:entrance", "core:stairs", "core:merchant"]
|
required = ["core:entrance", "core:stairs", "core:merchant"]
|
||||||
prefabs = [ { tag = "core:normal", weight = 10 },
|
prefabs = [ { tag = "core:normal", weight = 10 },
|
||||||
{ tag = "core:treasure", weight = 2 } ]
|
{ tag = "core:treasure", weight = 2 } ]
|
||||||
|
creatures = [ { id = "core:rat", weight = 6 }, { id = "core:skeleton", weight = 6 } ]
|
||||||
|
|
||||||
[[floors]]
|
[[floors]]
|
||||||
id = "floor_3"
|
id = "floor_3"
|
||||||
@@ -33,6 +35,7 @@ rooms = 9
|
|||||||
required = ["core:entrance", "core:stairs", "core:merchant"]
|
required = ["core:entrance", "core:stairs", "core:merchant"]
|
||||||
prefabs = [ { tag = "core:normal", weight = 10 },
|
prefabs = [ { tag = "core:normal", weight = 10 },
|
||||||
{ tag = "core:treasure", weight = 3 } ]
|
{ tag = "core:treasure", weight = 3 } ]
|
||||||
|
creatures = [ { id = "core:rat", weight = 3 }, { id = "core:skeleton", weight = 8 }, { id = "core:ghoul", weight = 3 } ]
|
||||||
|
|
||||||
[[floors]]
|
[[floors]]
|
||||||
id = "floor_4"
|
id = "floor_4"
|
||||||
@@ -42,6 +45,7 @@ rooms = 10
|
|||||||
required = ["core:entrance", "core:stairs", "core:merchant"]
|
required = ["core:entrance", "core:stairs", "core:merchant"]
|
||||||
prefabs = [ { tag = "core:normal", weight = 10 },
|
prefabs = [ { tag = "core:normal", weight = 10 },
|
||||||
{ tag = "core:treasure", weight = 3 } ]
|
{ tag = "core:treasure", weight = 3 } ]
|
||||||
|
creatures = [ { id = "core:skeleton", weight = 6 }, { id = "core:ghoul", weight = 8 } ]
|
||||||
|
|
||||||
[[floors]]
|
[[floors]]
|
||||||
id = "floor_5"
|
id = "floor_5"
|
||||||
@@ -51,3 +55,4 @@ rooms = 11
|
|||||||
required = ["core:entrance", "core:stairs", "core:merchant"]
|
required = ["core:entrance", "core:stairs", "core:merchant"]
|
||||||
prefabs = [ { tag = "core:normal", weight = 10 },
|
prefabs = [ { tag = "core:normal", weight = 10 },
|
||||||
{ tag = "core:treasure", weight = 4 } ]
|
{ tag = "core:treasure", weight = 4 } ]
|
||||||
|
creatures = [ { id = "core:ghoul", weight = 8 }, { id = "core:bone_lord", weight = 2 } ]
|
||||||
|
|||||||
43
mods/core/modifiers.toml
Normal file
43
mods/core/modifiers.toml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Benannte, befristete Modifikatoren — die dreizehnte Registry.
|
||||||
|
#
|
||||||
|
# Ticket 01 laesst `apply_modifier` einen Modifikator per ID referenzieren
|
||||||
|
# (`core:poisoned`), aber keine der zwoelf Registries definierte sie. Aufgefallen
|
||||||
|
# beim Bauen von M3.
|
||||||
|
#
|
||||||
|
# Der Schadenstyp `poison` ist NICHT der Modifikator `core:poisoned`: Der Typ
|
||||||
|
# sagt, wie gemindert wird; der Modifikator ist ein Zustand, der ueber Zuege
|
||||||
|
# tickt (Ticket 01).
|
||||||
|
#
|
||||||
|
# Eine Uhr: der Zug. Debuffs ticken auch beim Erkunden, damit "ich warte den
|
||||||
|
# Debuff draussen ab" eine Entscheidung mit Kosten bleibt.
|
||||||
|
|
||||||
|
[[modifiers]]
|
||||||
|
id = "poisoned"
|
||||||
|
name = "Vergiftet"
|
||||||
|
symbol = "☠"
|
||||||
|
color = "green"
|
||||||
|
turns = 4
|
||||||
|
attributes = [ { attribute = "damage", percent = -15 } ]
|
||||||
|
triggers = [
|
||||||
|
{ on = "turn_started",
|
||||||
|
actions = [ { action = "damage", amount = 2, damage_type = "poison", target = "self" } ] },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[modifiers]]
|
||||||
|
id = "blessed"
|
||||||
|
name = "Gesegnet"
|
||||||
|
symbol = "+"
|
||||||
|
color = "yellow"
|
||||||
|
turns = 6
|
||||||
|
attributes = [ { attribute = "damage", percent = 20 },
|
||||||
|
{ attribute = "critical_chance", flat = 10 } ]
|
||||||
|
|
||||||
|
[[modifiers]]
|
||||||
|
id = "warded"
|
||||||
|
name = "Abgeschirmt"
|
||||||
|
symbol = "o"
|
||||||
|
color = "cyan"
|
||||||
|
turns = 5
|
||||||
|
# Resistenz ist typ-indiziert und der EINZIGE Minderungsmechanismus (Ticket 01).
|
||||||
|
attributes = [ { attribute = "resistance", damage_type = "fire", flat = 40 },
|
||||||
|
{ attribute = "resistance", damage_type = "ice", flat = 40 } ]
|
||||||
@@ -14,7 +14,10 @@ use serde::Deserialize;
|
|||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use toml::Spanned;
|
use toml::Spanned;
|
||||||
|
|
||||||
use super::registries::{Category, CharacterOption, FloorDef, Item, Prefab, Registries, Slot};
|
use super::registries::{
|
||||||
|
Ability, Category, CharacterOption, CreatureDef, FloorDef, Item, ModifierDef, Prefab,
|
||||||
|
Registries, Slot,
|
||||||
|
};
|
||||||
|
|
||||||
/// Genau drei harte Fehler (Ticket 04). Alles Uebrige ist Warnung plus
|
/// Genau drei harte Fehler (Ticket 04). Alles Uebrige ist Warnung plus
|
||||||
/// Verwerfen des **einzelnen Eintrags**.
|
/// Verwerfen des **einzelnen Eintrags**.
|
||||||
@@ -248,6 +251,16 @@ struct ModFile {
|
|||||||
prefabs: Vec<Spanned<toml::Value>>,
|
prefabs: Vec<Spanned<toml::Value>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
floors: Vec<Spanned<toml::Value>>,
|
floors: Vec<Spanned<toml::Value>>,
|
||||||
|
#[serde(default)]
|
||||||
|
abilities: Vec<Spanned<toml::Value>>,
|
||||||
|
#[serde(default)]
|
||||||
|
creatures: Vec<Spanned<toml::Value>>,
|
||||||
|
#[serde(default)]
|
||||||
|
modifiers: Vec<Spanned<toml::Value>>,
|
||||||
|
/// **Keine** Tabelle mit `[[...]]`, sondern ein einzelner Block, der
|
||||||
|
/// feldweise verschmilzt — die ausdrueckliche Ausnahme zu Ticket 03.
|
||||||
|
#[serde(default)]
|
||||||
|
balance: Option<toml::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Damit die vier Registry-Typen durch dieselbe Einlese-Schleife passen.
|
/// Damit die vier Registry-Typen durch dieselbe Einlese-Schleife passen.
|
||||||
@@ -278,6 +291,9 @@ impl_entry!(Category);
|
|||||||
impl_entry!(CharacterOption);
|
impl_entry!(CharacterOption);
|
||||||
impl_entry!(Prefab);
|
impl_entry!(Prefab);
|
||||||
impl_entry!(FloorDef);
|
impl_entry!(FloorDef);
|
||||||
|
impl_entry!(Ability);
|
||||||
|
impl_entry!(CreatureDef);
|
||||||
|
impl_entry!(ModifierDef);
|
||||||
|
|
||||||
struct FileCtx<'a> {
|
struct FileCtx<'a> {
|
||||||
module: &'a str,
|
module: &'a str,
|
||||||
@@ -451,6 +467,7 @@ pub fn load_mods() -> Result<(Registries, Vec<String>), LoadError> {
|
|||||||
|
|
||||||
// Phase 2 — Einlesen in Ladereihenfolge
|
// Phase 2 — Einlesen in Ladereihenfolge
|
||||||
let mut reg = Registries::default();
|
let mut reg = Registries::default();
|
||||||
|
let mut balance_raw = toml::value::Table::new();
|
||||||
for id in &order {
|
for id in &order {
|
||||||
let Some(info) = found.iter().find(|m| &m.id == id) else { continue };
|
let Some(info) = found.iter().find(|m| &m.id == id) else { continue };
|
||||||
|
|
||||||
@@ -482,15 +499,44 @@ pub fn load_mods() -> Result<(Registries, Vec<String>), LoadError> {
|
|||||||
parse_entries(parsed.options, &ctx, &mut reg.options, &mut warnings);
|
parse_entries(parsed.options, &ctx, &mut reg.options, &mut warnings);
|
||||||
parse_entries(parsed.prefabs, &ctx, &mut reg.prefabs, &mut warnings);
|
parse_entries(parsed.prefabs, &ctx, &mut reg.prefabs, &mut warnings);
|
||||||
parse_entries(parsed.floors, &ctx, &mut reg.floors, &mut warnings);
|
parse_entries(parsed.floors, &ctx, &mut reg.floors, &mut warnings);
|
||||||
|
parse_entries(parsed.abilities, &ctx, &mut reg.abilities, &mut warnings);
|
||||||
|
parse_entries(parsed.creatures, &ctx, &mut reg.creatures, &mut warnings);
|
||||||
|
parse_entries(parsed.modifiers, &ctx, &mut reg.modifiers, &mut warnings);
|
||||||
|
|
||||||
|
// `[balance]` verschmilzt **feldweise** (Ticket 14): Ein Mod, der
|
||||||
|
// einen Knopf dreht, schreibt eine Zeile — und ein neuer Knopf in
|
||||||
|
// der naechsten Version bricht ihn nicht.
|
||||||
|
if let Some(table) = parsed.balance {
|
||||||
|
merge_balance(table, &mut balance_raw);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `[balance]` erst am Ende deserialisieren: Fehlende Felder fallen dann auf
|
||||||
|
// die Engine-Standards zurueck, nicht auf die des zuletzt geladenen Mods.
|
||||||
|
match toml::Value::Table(balance_raw).try_into() {
|
||||||
|
Ok(b) => reg.balance = b,
|
||||||
|
Err(e) => warnings.push(format!("[balance]: {} — Standardwerte gelten", e.message())),
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 3 — Referenzen aufloesen
|
// Phase 3 — Referenzen aufloesen
|
||||||
resolve(&mut reg, &mut warnings);
|
resolve(&mut reg, &mut warnings);
|
||||||
|
|
||||||
Ok((reg, warnings))
|
Ok((reg, warnings))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Feldweises Verschmelzen. Die Ausnahme bleibt eng, weil ihre Begruendung eng
|
||||||
|
/// ist: `[balance]` hat ein **festes, der Engine bekanntes Schema** — genau das
|
||||||
|
/// fehlte bei Inhalt mit offenen Feldern, und das war Ticket 03s Argument gegen
|
||||||
|
/// feldweises Patchen.
|
||||||
|
fn merge_balance(from: toml::Value, into: &mut toml::value::Table) {
|
||||||
|
if let toml::Value::Table(t) = from {
|
||||||
|
for (k, v) in t {
|
||||||
|
into.insert(k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn toml_files(dir: &Path) -> Vec<PathBuf> {
|
fn toml_files(dir: &Path) -> Vec<PathBuf> {
|
||||||
let mut files: Vec<PathBuf> = fs::read_dir(dir)
|
let mut files: Vec<PathBuf> = fs::read_dir(dir)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@@ -5,4 +5,4 @@ pub mod loader;
|
|||||||
pub mod registries;
|
pub mod registries;
|
||||||
|
|
||||||
pub use loader::{load_mods, write_log};
|
pub use loader::{load_mods, write_log};
|
||||||
pub use registries::{FloorDef, Prefab, Registries};
|
pub use registries::{Balance, CreatureDef, FloorDef, Prefab, Registries};
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::game::action::Action;
|
||||||
|
use crate::game::condition::Condition;
|
||||||
|
use crate::game::event::Trigger;
|
||||||
use crate::game::Modifier;
|
use crate::game::Modifier;
|
||||||
|
|
||||||
/// Ein Ding im Spiel, **einheitlich strukturiert**. Es gibt keinen Item-Typ:
|
/// Ein Ding im Spiel, **einheitlich strukturiert**. Es gibt keinen Item-Typ:
|
||||||
@@ -92,6 +95,132 @@ pub struct CharacterOption {
|
|||||||
// Startausruestung brauchen Kampf bzw. Inventar.
|
// Startausruestung brauchen Kampf bzw. Inventar.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ein benannter, befristeter Modifikator — „Vergiftet", „Gesegnet".
|
||||||
|
///
|
||||||
|
/// **Befund aus M3:** Ticket 01 laesst `apply_modifier` einen Modifikator per ID
|
||||||
|
/// referenzieren (`core:poisoned`), aber keine der zwoelf Registries definiert
|
||||||
|
/// benannte Modifikatoren. Das hier ist die **dreizehnte**.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct ModifierDef {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default = "default_symbol")]
|
||||||
|
pub symbol: char,
|
||||||
|
#[serde(default)]
|
||||||
|
pub color: Option<String>,
|
||||||
|
/// Zuege bis zum Verfall. Eine Uhr: der Zug (Ticket 01) — Debuffs ticken
|
||||||
|
/// auch beim Erkunden, damit „ich warte den Debuff draussen ab" eine
|
||||||
|
/// Entscheidung mit Kosten bleibt.
|
||||||
|
#[serde(default = "three")]
|
||||||
|
pub turns: u32,
|
||||||
|
/// Die Umrechnungen, die er traegt. Bewusst `attributes` und nicht
|
||||||
|
/// `modifiers`, damit ein Modifikator nicht Modifikatoren enthaelt.
|
||||||
|
#[serde(default)]
|
||||||
|
pub attributes: Vec<Modifier>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub triggers: Vec<Trigger>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub overrides: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn three() -> u32 {
|
||||||
|
3
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein benanntes, wiederholt ausloesbares **Angebot** an den Spieler
|
||||||
|
/// (Ticket 02) mit seiner Kampfsemantik aus Ticket 14.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Ability {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: String,
|
||||||
|
/// Ausdauer — kein zweiter Ressourcenpool (Ticket 14).
|
||||||
|
#[serde(default)]
|
||||||
|
pub cost: f32,
|
||||||
|
/// Abklingzeit in Zuegen.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cooldown: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub condition: Vec<Condition>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub actions: Vec<Action>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub overrides: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Eintrag der `ai`-Liste: erster passender **und verfuegbarer** gewinnt
|
||||||
|
/// (Ticket 14). Ein Eintrag ohne `condition` greift immer.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct AiEntry {
|
||||||
|
#[serde(default)]
|
||||||
|
pub condition: Vec<Condition>,
|
||||||
|
pub ability: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gegnerdefinition. Die Luecke lag seit Ticket 02 offen — `Creature` war der
|
||||||
|
/// Laufzeittyp, woher seine Werte kommen, hat nie jemand gesagt (Ticket 14).
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct CreatureDef {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default = "default_symbol")]
|
||||||
|
pub symbol: char,
|
||||||
|
#[serde(default)]
|
||||||
|
pub color: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
/// Grundwerte. Was fehlt, bleibt beim Standard.
|
||||||
|
#[serde(default)]
|
||||||
|
pub base: HashMap<String, f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub abilities: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ai: Vec<AiEntry>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub triggers: Vec<Trigger>,
|
||||||
|
/// Kurzform fuer einen Todes-Trigger, den sonst jede Gegnerdefinition
|
||||||
|
/// mitschleppen muesste (Ticket 15).
|
||||||
|
#[serde(default)]
|
||||||
|
pub exp_reward: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub overrides: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Stellknoepfe aus Ticket 14 und 16. **Keine Registry**: ein einzelner
|
||||||
|
/// Block mit festem Schema, der **feldweise** verschmilzt statt als ganzer
|
||||||
|
/// Eintrag ersetzt zu werden — sonst muesste ein Mod, der nur `ausweich` dreht,
|
||||||
|
/// alle Werte abschreiben, und ein neuer Knopf braeche ihn stillschweigend.
|
||||||
|
///
|
||||||
|
/// Die Standardwerte liegen hier in der Engine, nicht in `core`.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Balance {
|
||||||
|
pub ausweich: f32,
|
||||||
|
pub flucht: f32,
|
||||||
|
pub stamina_regen: f32,
|
||||||
|
/// Deckel, damit ein Wesen nicht immun wird — Immunitaet ist keine Zahl,
|
||||||
|
/// sondern ein Spielabbruch (Ticket 14).
|
||||||
|
pub resistance_cap: f32,
|
||||||
|
pub hit_floor: f32,
|
||||||
|
pub hit_ceiling: f32,
|
||||||
|
// TODO(M4/M5): `exp_base`, `exp_exponent` (Ticket 15) und `luck_factor`
|
||||||
|
// (Ticket 16) — sie kommen mit ihren Lesern, nicht davor.
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Balance {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
ausweich: 0.02,
|
||||||
|
flucht: 0.05,
|
||||||
|
stamina_regen: 4.0,
|
||||||
|
resistance_cap: 0.8,
|
||||||
|
hit_floor: 0.05,
|
||||||
|
hit_ceiling: 0.95,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Ein von Hand gezeichneter Raum als ASCII-Block (Ticket 10).
|
/// Ein von Hand gezeichneter Raum als ASCII-Block (Ticket 10).
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct Prefab {
|
pub struct Prefab {
|
||||||
@@ -142,12 +271,22 @@ pub struct FloorDef {
|
|||||||
pub required: Vec<String>,
|
pub required: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub prefabs: Vec<WeightedTag>,
|
pub prefabs: Vec<WeightedTag>,
|
||||||
|
/// Welche Gegner die `e`-Plaetze fuellen. Gewichtet, per **ID** — anders
|
||||||
|
/// als bei Prefabs, weil eine Etage ihre Gegner benennen koennen soll.
|
||||||
|
#[serde(default)]
|
||||||
|
pub creatures: Vec<WeightedId>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub overrides: Option<String>,
|
pub overrides: Option<String>,
|
||||||
// TODO(M3): `creatures` — welche Gegner die `e`-Plaetze fuellen.
|
|
||||||
// TODO(M4): `loot` — die Beutetabelle fuer die `l`-Plaetze (Ticket 16).
|
// TODO(M4): `loot` — die Beutetabelle fuer die `l`-Plaetze (Ticket 16).
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct WeightedId {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default = "one")]
|
||||||
|
pub weight: u32,
|
||||||
|
}
|
||||||
|
|
||||||
/// Alle geladenen Definitionen. Liegt **neben** `Game`, nicht darin — als Feld
|
/// Alle geladenen Definitionen. Liegt **neben** `Game`, nicht darin — als Feld
|
||||||
/// kollidierte `&mut game.player` mit `&game.registries` bei jedem
|
/// kollidierte `&mut game.player` mit `&game.registries` bei jedem
|
||||||
/// Methodenaufruf (Ticket 07).
|
/// Methodenaufruf (Ticket 07).
|
||||||
@@ -159,8 +298,12 @@ pub struct Registries {
|
|||||||
pub options: HashMap<String, CharacterOption>,
|
pub options: HashMap<String, CharacterOption>,
|
||||||
pub prefabs: HashMap<String, Prefab>,
|
pub prefabs: HashMap<String, Prefab>,
|
||||||
pub floors: HashMap<String, FloorDef>,
|
pub floors: HashMap<String, FloorDef>,
|
||||||
// TODO(M3+): `affixes`, `abilities`, `quests`, `creatures`, `loot_tables`,
|
pub abilities: HashMap<String, Ability>,
|
||||||
// `scripts` — zwoelf sind es am Ende.
|
pub creatures: HashMap<String, CreatureDef>,
|
||||||
|
pub modifiers: HashMap<String, ModifierDef>,
|
||||||
|
/// Keine Registry — ein Block mit festem Schema, feldweise verschmolzen.
|
||||||
|
pub balance: Balance,
|
||||||
|
// TODO(M4+): `affixes`, `quests`, `loot_tables`, `scripts`.
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Registries {
|
impl Registries {
|
||||||
|
|||||||
190
src/game/action.rs
Normal file
190
src/game/action.rs
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
//! Aktionen und Schadenstypen — Ticket 01.
|
||||||
|
//!
|
||||||
|
//! Eine **Aktion** ist eine einmalige Zustandsaenderung. Sie feuert, wirkt, ist
|
||||||
|
//! vorbei. Getrennte Maschine vom Modifikator, der nie ausgefuehrt, sondern beim
|
||||||
|
//! Auslesen aufsummiert wird.
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
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)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[repr(usize)]
|
||||||
|
pub enum DamageType {
|
||||||
|
Fire,
|
||||||
|
Ice,
|
||||||
|
Poison,
|
||||||
|
Shock,
|
||||||
|
Holy,
|
||||||
|
Slash,
|
||||||
|
Blunt,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DamageType {
|
||||||
|
pub const ALL: [DamageType; 7] = [
|
||||||
|
DamageType::Fire,
|
||||||
|
DamageType::Ice,
|
||||||
|
DamageType::Poison,
|
||||||
|
DamageType::Shock,
|
||||||
|
DamageType::Holy,
|
||||||
|
DamageType::Slash,
|
||||||
|
DamageType::Blunt,
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
DamageType::Fire => "Feuer",
|
||||||
|
DamageType::Ice => "Eis",
|
||||||
|
DamageType::Poison => "Gift",
|
||||||
|
DamageType::Shock => "Blitz",
|
||||||
|
DamageType::Holy => "Heilig",
|
||||||
|
DamageType::Slash => "Schnitt",
|
||||||
|
DamageType::Blunt => "Stumpf",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wen eine Aktion trifft. Aufgeloest wird gegen die Adressen des laufenden
|
||||||
|
/// Zuges (Ticket 01).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Target {
|
||||||
|
#[default]
|
||||||
|
#[serde(rename = "self")]
|
||||||
|
Zelf,
|
||||||
|
/// Heisst in der TOML `target`; der Rust-Name weicht ab, weil
|
||||||
|
/// `Target::Target` sich liest wie ein Tippfehler.
|
||||||
|
#[serde(rename = "target")]
|
||||||
|
Other,
|
||||||
|
AllEnemies,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Aktionskatalog. Zehn Eintraege sind entschieden (01, 08, 13, 14, 15);
|
||||||
|
/// M3 braucht fuenf davon.
|
||||||
|
///
|
||||||
|
// TODO(M4+): `give_item`, `remove_item` (Inventar), `start_quest` (08),
|
||||||
|
// `give_exp` (15), `run_script` (13).
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(tag = "action", rename_all = "snake_case")]
|
||||||
|
pub enum Action {
|
||||||
|
/// Eigenstaendig statt `change_attribute health -12`, weil Schaden durch
|
||||||
|
/// eine Kette laeuft: Resistenz mindert, Krit vervielfacht, „bei erlittenem
|
||||||
|
/// Schaden" ist ein Event (Ticket 01).
|
||||||
|
///
|
||||||
|
/// Menge = `amount + scale * damage-Attribut(Verursacher)` — eine Formel
|
||||||
|
/// fuer Basisangriff (`scale = 1.0`), Feuerball (`amount = 20`) und
|
||||||
|
/// Wuchtschlag (`scale = 1.5`), Nachtrag aus Ticket 14.
|
||||||
|
Damage {
|
||||||
|
#[serde(default)]
|
||||||
|
amount: f32,
|
||||||
|
#[serde(default)]
|
||||||
|
scale: f32,
|
||||||
|
damage_type: DamageType,
|
||||||
|
#[serde(default)]
|
||||||
|
target: Target,
|
||||||
|
},
|
||||||
|
/// Aendert **Zustand**, nicht Attribute: `health` und `stamina`. Ein
|
||||||
|
/// Modifikator wirkt auf `max_health`, eine Aktion auf den Lebensstand —
|
||||||
|
/// die Trennung muss scharf bleiben, sonst heilt ein Ring beim Anlegen.
|
||||||
|
ChangeAttribute {
|
||||||
|
attribute: StateField,
|
||||||
|
amount: f32,
|
||||||
|
#[serde(default)]
|
||||||
|
target: Target,
|
||||||
|
},
|
||||||
|
ApplyModifier {
|
||||||
|
modifier: String,
|
||||||
|
#[serde(default)]
|
||||||
|
target: Target,
|
||||||
|
},
|
||||||
|
RemoveModifier {
|
||||||
|
modifier: String,
|
||||||
|
#[serde(default)]
|
||||||
|
target: Target,
|
||||||
|
},
|
||||||
|
/// Ticket 14: achte Aktion, weil Ticket 11 Flucht zu einer Faehigkeit
|
||||||
|
/// gemacht hat und die etwas braucht, das sie ausfuehrt.
|
||||||
|
Flee,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die beiden Zustandsgroessen, die eine Aktion aendern darf. Bewusst **kein**
|
||||||
|
/// [`Attribute`]: Attribute werden gerechnet, Zustand wird gesetzt.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum StateField {
|
||||||
|
Health,
|
||||||
|
Stamina,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Action {
|
||||||
|
pub fn target(&self) -> Target {
|
||||||
|
match self {
|
||||||
|
Action::Damage { target, .. }
|
||||||
|
| Action::ChangeAttribute { target, .. }
|
||||||
|
| Action::ApplyModifier { target, .. }
|
||||||
|
| Action::RemoveModifier { target, .. } => *target,
|
||||||
|
Action::Flee => Target::Zelf,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)]
|
||||||
|
pub struct ActiveModifier {
|
||||||
|
/// ID des Registry-Eintrags, damit `remove_modifier` ihn findet.
|
||||||
|
pub id: String,
|
||||||
|
pub attribute: Attribute,
|
||||||
|
pub damage_type: Option<DamageType>,
|
||||||
|
pub flat: f32,
|
||||||
|
pub percent: f32,
|
||||||
|
pub turns_left: u32,
|
||||||
|
pub name: String,
|
||||||
|
pub symbol: char,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn aktionen_lesen_sich_aus_toml() {
|
||||||
|
// Genau die Schreibweise aus Ticket 01.
|
||||||
|
let toml = r#"
|
||||||
|
actions = [
|
||||||
|
{ action = "damage", amount = 12, damage_type = "fire", target = "target" },
|
||||||
|
{ action = "change_attribute", attribute = "health", amount = 20, target = "self" },
|
||||||
|
{ action = "apply_modifier", modifier = "core:poisoned", target = "target" },
|
||||||
|
{ action = "flee" },
|
||||||
|
]
|
||||||
|
"#;
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Wrap {
|
||||||
|
actions: Vec<Action>,
|
||||||
|
}
|
||||||
|
let w: Wrap = toml::from_str(toml).expect("Vokabular aus Ticket 01 muss lesbar sein");
|
||||||
|
assert_eq!(w.actions.len(), 4);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
w.actions[0],
|
||||||
|
Action::Damage { amount: 12.0, damage_type: DamageType::Fire, target: Target::Other, .. }
|
||||||
|
));
|
||||||
|
// `target` fehlt beim vierten Eintrag und faellt auf `self` zurueck.
|
||||||
|
assert_eq!(w.actions[3].target(), Target::Zelf);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skalierender_schaden_ohne_amount() {
|
||||||
|
let a: Action = toml::from_str(
|
||||||
|
r#"action = "damage"
|
||||||
|
scale = 1.5
|
||||||
|
damage_type = "blunt""#,
|
||||||
|
)
|
||||||
|
.expect("scale allein muss reichen");
|
||||||
|
assert!(matches!(a, Action::Damage { amount: 0.0, scale: 1.5, .. }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,10 +8,6 @@ use serde::Deserialize;
|
|||||||
/// Fester Katalog aus Ticket 01. `level` steht hier, weil Bedingungen und
|
/// Fester Katalog aus Ticket 01. `level` steht hier, weil Bedingungen und
|
||||||
/// Formeln es lesen — die Quelle fuer Aufstiegspunkte ist aber `base.level`
|
/// Formeln es lesen — die Quelle fuer Aufstiegspunkte ist aber `base.level`
|
||||||
/// und nicht dieser gelesene Wert (Ticket 15).
|
/// 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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
#[repr(usize)]
|
#[repr(usize)]
|
||||||
@@ -30,10 +26,14 @@ pub enum Attribute {
|
|||||||
CriticalFactor,
|
CriticalFactor,
|
||||||
PriceModifier,
|
PriceModifier,
|
||||||
CarryCapacity,
|
CarryCapacity,
|
||||||
|
/// **Typ-indiziert**: Ein Modifikator darauf traegt zusaetzlich einen
|
||||||
|
/// `damage_type`. Die Basis in `Attributes` bleibt ungenutzt — Resistenz
|
||||||
|
/// kommt ausschliesslich aus Modifikatoren (Ticket 01).
|
||||||
|
Resistance,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Attribute {
|
impl Attribute {
|
||||||
pub const COUNT: usize = 14;
|
pub const COUNT: usize = 15;
|
||||||
|
|
||||||
pub const ALL: [Attribute; Self::COUNT] = [
|
pub const ALL: [Attribute; Self::COUNT] = [
|
||||||
Attribute::Strength,
|
Attribute::Strength,
|
||||||
@@ -50,6 +50,7 @@ impl Attribute {
|
|||||||
Attribute::CriticalFactor,
|
Attribute::CriticalFactor,
|
||||||
Attribute::PriceModifier,
|
Attribute::PriceModifier,
|
||||||
Attribute::CarryCapacity,
|
Attribute::CarryCapacity,
|
||||||
|
Attribute::Resistance,
|
||||||
];
|
];
|
||||||
|
|
||||||
pub fn name(self) -> &'static str {
|
pub fn name(self) -> &'static str {
|
||||||
@@ -68,6 +69,7 @@ impl Attribute {
|
|||||||
Attribute::CriticalFactor => "Kritischer Faktor",
|
Attribute::CriticalFactor => "Kritischer Faktor",
|
||||||
Attribute::PriceModifier => "Preis-Modifikator",
|
Attribute::PriceModifier => "Preis-Modifikator",
|
||||||
Attribute::CarryCapacity => "Tragfähigkeit",
|
Attribute::CarryCapacity => "Tragfähigkeit",
|
||||||
|
Attribute::Resistance => "Resistenz",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,12 +116,18 @@ impl Default for Attributes {
|
|||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct Modifier {
|
pub struct Modifier {
|
||||||
pub attribute: Attribute,
|
pub attribute: Attribute,
|
||||||
|
/// Nur bei `attribute = "resistance"`: Resistenz ist typ-indiziert
|
||||||
|
/// (Ticket 01), alle uebrigen Attribute sind Skalare.
|
||||||
|
#[serde(default)]
|
||||||
|
pub damage_type: Option<super::action::DamageType>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub flat: f32,
|
pub flat: f32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub percent: f32,
|
pub percent: f32,
|
||||||
// TODO(M3): `duration`, `target` und `condition` aus Ticket 01/08. In M1
|
// TODO(M4): `target` fuer Auren (`all_enemies`) und `condition` fuer
|
||||||
// sind alle Modifikatoren dauerhaft und wirken auf den Traeger.
|
// bedingte Modifikatoren („+5 gegen Untote", Ticket 08). Beide
|
||||||
|
// brauchen `Game::attribute`s `against`-Parameter, der bereits
|
||||||
|
// in der Signatur steht.
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stapelregel aus Ticket 01: `(Basis + Σ flach) × (1 + Σ prozentual)`,
|
/// Stapelregel aus Ticket 01: `(Basis + Σ flach) × (1 + Σ prozentual)`,
|
||||||
@@ -149,11 +157,17 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stapelregel_erst_flach_dann_prozentual() {
|
fn stapelregel_erst_flach_dann_prozentual() {
|
||||||
|
let m = |attribute, flat, percent| Modifier {
|
||||||
|
attribute,
|
||||||
|
damage_type: None,
|
||||||
|
flat,
|
||||||
|
percent,
|
||||||
|
};
|
||||||
let mods = [
|
let mods = [
|
||||||
Modifier { attribute: Attribute::Strength, flat: 2.0, percent: 0.0 },
|
m(Attribute::Strength, 2.0, 0.0),
|
||||||
Modifier { attribute: Attribute::Strength, flat: 3.0, percent: 10.0 },
|
m(Attribute::Strength, 3.0, 10.0),
|
||||||
// Fremdes Attribut darf nicht mitzaehlen:
|
// Fremdes Attribut darf nicht mitzaehlen:
|
||||||
Modifier { attribute: Attribute::Damage, flat: 100.0, percent: 0.0 },
|
m(Attribute::Damage, 100.0, 0.0),
|
||||||
];
|
];
|
||||||
// (10 + 5) * 1.10 == 16.5, nicht 10*1.1 + 5 == 16.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);
|
assert_eq!(stack(10.0, mods.iter(), Attribute::Strength), 16.5);
|
||||||
|
|||||||
99
src/game/combat.rs
Normal file
99
src/game/combat.rs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
//! Kampfformeln — Ticket 14.
|
||||||
|
//!
|
||||||
|
//! Zahlen stehen hier keine: Die fuenf Stellknoepfe liegen in `[balance]` und
|
||||||
|
//! sind moddbar. Was hier steht, sind die **Formen** und die drei Klemmbereiche,
|
||||||
|
//! ohne die das Spiel Sackgassen erzeugt.
|
||||||
|
|
||||||
|
use crate::data::Balance;
|
||||||
|
|
||||||
|
/// `p = clamp(hit_chance − dexterity_ziel × AUSWEICH, 0.05, 0.95)`
|
||||||
|
///
|
||||||
|
/// Der Klemmbereich ist nicht Kosmetik: Ohne ihn wird ein hochgezuechteter
|
||||||
|
/// Verteidiger unangreifbar und ein hochgezuechteter Angreifer unfehlbar —
|
||||||
|
/// beides Sackgassen, die man erst auf Etage 5 bemerkt.
|
||||||
|
pub fn hit_chance(attacker_hit: f32, defender_dex: f32, b: &Balance) -> f32 {
|
||||||
|
(attacker_hit - defender_dex * b.ausweich).clamp(b.hit_floor, b.hit_ceiling)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `p = clamp(0.5 + (dex_held − dex_schnellster) × FLUCHT, 0.1, 0.9)`
|
||||||
|
pub fn flee_chance(own_dex: f32, fastest_enemy_dex: f32, b: &Balance) -> f32 {
|
||||||
|
(0.5 + (own_dex - fastest_enemy_dex) * b.flucht).clamp(0.1, 0.9)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Roher Schaden vor der Minderung: `amount + scale × damage-Attribut`.
|
||||||
|
pub fn raw_damage(amount: f32, scale: f32, attacker_damage: f32) -> f32 {
|
||||||
|
amount + scale * attacker_damage
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Minderungskette. Ob `critical_factor` den Rohschaden oder den geminderten
|
||||||
|
/// multipliziert, ist **mathematisch belanglos** — solange beide multiplikativ
|
||||||
|
/// sind, ist `roh × kf × (1−r)` dasselbe wie `roh × (1−r) × kf`. Festzulegen war
|
||||||
|
/// nur, dass sie multiplikativ *bleiben*.
|
||||||
|
pub fn final_damage(raw: f32, critical: bool, crit_factor: f32, resistance: f32, b: &Balance) -> u32 {
|
||||||
|
let after_crit = if critical { raw * crit_factor } else { raw };
|
||||||
|
// Deckel bei 0.8: Bei 1.0 waere ein Wesen immun, und Immunitaet ist keine
|
||||||
|
// Zahl, sondern ein Spielabbruch. Nach unten bis −1.0 fuer Verwundbarkeit.
|
||||||
|
let r = resistance.clamp(-1.0, b.resistance_cap);
|
||||||
|
// `max(1)`: Ein Treffer macht nie null, sonst gibt es Kaempfe, die
|
||||||
|
// rechnerisch nie enden.
|
||||||
|
(after_crit * (1.0 - r)).round().max(1.0) as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn b() -> Balance {
|
||||||
|
Balance::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trefferchance_bleibt_entscheidbar() {
|
||||||
|
let b = b();
|
||||||
|
// Ein absurd flinker Verteidiger bleibt treffbar.
|
||||||
|
assert_eq!(hit_chance(0.8, 10_000.0, &b), b.hit_floor);
|
||||||
|
// Ein absurd treffsicherer Angreifer verfehlt trotzdem manchmal.
|
||||||
|
assert_eq!(hit_chance(99.0, 0.0, &b), b.hit_ceiling);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resistenz_macht_nicht_immun() {
|
||||||
|
let b = b();
|
||||||
|
// 100 % Resistenz waere Immunitaet — der Deckel verhindert das.
|
||||||
|
assert!(final_damage(100.0, false, 1.5, 1.0, &b) > 0);
|
||||||
|
assert_eq!(final_damage(100.0, false, 1.5, 1.0, &b), 20, "80 % gedeckelt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verwundbarkeit_ist_ausdrueckbar() {
|
||||||
|
// Negative Resistenz = mehr Schaden. „Untote nehmen doppelten heiligen
|
||||||
|
// Schaden" braucht das.
|
||||||
|
assert_eq!(final_damage(10.0, false, 1.5, -1.0, &b()), 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ein_treffer_macht_nie_null() {
|
||||||
|
assert_eq!(final_damage(0.4, false, 1.5, 0.8, &b()), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn krit_reihenfolge_ist_belanglos() {
|
||||||
|
let b = b();
|
||||||
|
let (roh, kf, r) = (37.0f32, 1.7f32, 0.35f32);
|
||||||
|
// Beide Reihenfolgen, direkt ausgerechnet.
|
||||||
|
let erst_krit = (roh * kf) * (1.0 - r);
|
||||||
|
let erst_minderung = (roh * (1.0 - r)) * kf;
|
||||||
|
assert!((erst_krit - erst_minderung).abs() < 0.0001);
|
||||||
|
assert_eq!(final_damage(roh, true, kf, r, &b), erst_krit.round() as u32);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skalierender_schaden() {
|
||||||
|
// Basisangriff: scale 1.0 auf 7 Schaden.
|
||||||
|
assert_eq!(raw_damage(0.0, 1.0, 7.0), 7.0);
|
||||||
|
// Feuerball: fester Betrag, unabhaengig von der Waffe.
|
||||||
|
assert_eq!(raw_damage(20.0, 0.0, 7.0), 20.0);
|
||||||
|
// Wuchtschlag: 150 % Waffenschaden.
|
||||||
|
assert_eq!(raw_damage(0.0, 1.5, 8.0), 12.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,14 @@ pub enum Command {
|
|||||||
Move(Direction),
|
Move(Direction),
|
||||||
Wait,
|
Wait,
|
||||||
Descend,
|
Descend,
|
||||||
|
/// Der normale Angriff ist **keine eigene Variante**, sondern
|
||||||
|
/// `UseAbility("core:attack")` — Faehigkeiten sind seit Ticket 02 eine
|
||||||
|
/// Registry, also gehoert er dorthin, und ein Mod kann ihn ersetzen.
|
||||||
|
/// Dasselbe gilt fuer die Flucht.
|
||||||
|
UseAbility {
|
||||||
|
ability: String,
|
||||||
|
target: super::creature::CreatureRef,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ergebnis eines Kommandos — der Befund aus dem Durchstich in Ticket 12:
|
/// Ergebnis eines Kommandos — der Befund aus dem Durchstich in Ticket 12:
|
||||||
|
|||||||
112
src/game/condition.rs
Normal file
112
src/game/condition.rs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
//! Bedingungen — Ticket 08.
|
||||||
|
//!
|
||||||
|
//! **Eine** Grammatik fuer Trigger *und* Modifikatoren, damit sie sich nicht
|
||||||
|
//! subtil auseinanderentwickeln. Eine Liste ist implizit UND, jeder Eintrag ist
|
||||||
|
//! ueber `not` negierbar; es gibt **kein ODER** und keine Verschachtelung.
|
||||||
|
//!
|
||||||
|
//! Zwei Moeglichkeiten werden zu zwei Eintraegen, oder der Modder fasst sie
|
||||||
|
//! ueber ein gemeinsames Tag zusammen — ein rekursiver Ausdrucksbaum braeuchte
|
||||||
|
//! eine Klammer-Syntax, die in TOML niemand freiwillig schreibt.
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use super::attributes::Attribute;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Who {
|
||||||
|
#[default]
|
||||||
|
#[serde(rename = "self")]
|
||||||
|
Zelf,
|
||||||
|
Target,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(tag = "check", rename_all = "snake_case")]
|
||||||
|
pub enum Check {
|
||||||
|
HasTag {
|
||||||
|
#[serde(default)]
|
||||||
|
who: Who,
|
||||||
|
value: String,
|
||||||
|
},
|
||||||
|
Attribute {
|
||||||
|
#[serde(default)]
|
||||||
|
who: Who,
|
||||||
|
attribute: Attribute,
|
||||||
|
#[serde(default)]
|
||||||
|
at_least: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
below: Option<f32>,
|
||||||
|
},
|
||||||
|
HasItem {
|
||||||
|
#[serde(default)]
|
||||||
|
who: Who,
|
||||||
|
item: String,
|
||||||
|
#[serde(default = "one")]
|
||||||
|
count: u32,
|
||||||
|
},
|
||||||
|
/// Anteil am Maximum, 0.0–1.0. Fuer `ai`-Listen wie „unter 30 % Leben".
|
||||||
|
HealthBelow {
|
||||||
|
#[serde(default)]
|
||||||
|
who: Who,
|
||||||
|
value: f32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fn one() -> u32 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Condition {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub check: Check,
|
||||||
|
/// Kein ODER, aber Negation je Eintrag.
|
||||||
|
#[serde(default)]
|
||||||
|
pub not: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Condition {
|
||||||
|
pub fn who(&self) -> Who {
|
||||||
|
match &self.check {
|
||||||
|
Check::HasTag { who, .. }
|
||||||
|
| Check::Attribute { who, .. }
|
||||||
|
| Check::HasItem { who, .. }
|
||||||
|
| Check::HealthBelow { who, .. } => *who,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grammatik_aus_ticket_08_liest_sich() {
|
||||||
|
let toml = r#"
|
||||||
|
condition = [
|
||||||
|
{ check = "has_tag", who = "target", value = "core:undead" },
|
||||||
|
{ check = "attribute", who = "self", attribute = "level", at_least = 3 },
|
||||||
|
{ check = "has_item", who = "self", item = "core:key", count = 1, not = true },
|
||||||
|
]
|
||||||
|
"#;
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Wrap {
|
||||||
|
condition: Vec<Condition>,
|
||||||
|
}
|
||||||
|
let w: Wrap = toml::from_str(toml).expect("Grammatik aus Ticket 08 muss lesbar sein");
|
||||||
|
assert_eq!(w.condition.len(), 3);
|
||||||
|
|
||||||
|
assert!(matches!(w.condition[0].check, Check::HasTag { .. }));
|
||||||
|
assert_eq!(w.condition[0].who(), Who::Target);
|
||||||
|
assert!(!w.condition[0].not);
|
||||||
|
assert!(w.condition[2].not, "`not` muss neben dem Check stehen duerfen");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn who_faellt_auf_self_zurueck() {
|
||||||
|
let c: Condition =
|
||||||
|
toml::from_str("check = \"has_tag\"\nvalue = \"core:rat\"").expect("minimal");
|
||||||
|
assert_eq!(c.who(), Who::Zelf);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,8 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use super::attributes::Attributes;
|
use super::action::ActiveModifier;
|
||||||
|
use super::attributes::{Attribute, Attributes};
|
||||||
|
|
||||||
/// Ein Exemplar eines Gegenstands. Traegt **nur IDs** — die Affixe sind
|
/// Ein Exemplar eines Gegenstands. Traegt **nur IDs** — die Affixe sind
|
||||||
/// Registry-Eintraege, keine eingebetteten Modifikatoren (Ticket 07). Genau
|
/// Registry-Eintraege, keine eingebetteten Modifikatoren (Ticket 07). Genau
|
||||||
@@ -51,9 +52,20 @@ pub struct Creature {
|
|||||||
pub inventory: Vec<ItemInstance>,
|
pub inventory: Vec<ItemInstance>,
|
||||||
pub equipment: HashMap<String, ItemInstance>,
|
pub equipment: HashMap<String, ItemInstance>,
|
||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
// TODO(M3): `modifiers: Vec<ActiveModifier>` (nur befristete),
|
/// **Nur befristete** Modifikatoren. Rasse und Klasse kommen aus `options`,
|
||||||
// `initiative: i32`, `cooldowns: HashMap<AbilityId, u32>`.
|
/// Ausruestung aus `equipment` — hineinzukopieren machte das Lesen billiger
|
||||||
// Alle drei brauchen den Kampf, um ueberhaupt etwas zu tun.
|
/// 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 cooldowns: HashMap<String, u32>,
|
||||||
|
/// 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 {
|
impl Creature {
|
||||||
@@ -61,13 +73,43 @@ impl Creature {
|
|||||||
let base = Attributes::default();
|
let base = Attributes::default();
|
||||||
Self {
|
Self {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
health: base.get(super::attributes::Attribute::MaxHealth),
|
health: base.get(Attribute::MaxHealth),
|
||||||
stamina: base.get(super::attributes::Attribute::MaxStamina),
|
stamina: base.get(Attribute::MaxStamina),
|
||||||
base,
|
base,
|
||||||
options: Vec::new(),
|
options: Vec::new(),
|
||||||
inventory: Vec::new(),
|
inventory: Vec::new(),
|
||||||
equipment: HashMap::new(),
|
equipment: HashMap::new(),
|
||||||
tags: Vec::new(),
|
tags: Vec::new(),
|
||||||
|
modifiers: Vec::new(),
|
||||||
|
initiative: 0,
|
||||||
|
cooldowns: HashMap::new(),
|
||||||
|
def: None,
|
||||||
|
abilities: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Baut ein Wesen aus seiner Registry-Definition (Ticket 14). Attribute,
|
||||||
|
/// die die Definition nicht nennt, bleiben beim Standard.
|
||||||
|
pub fn from_def(def: &crate::data::CreatureDef) -> Self {
|
||||||
|
let mut c = Creature::new(def.name.clone());
|
||||||
|
c.def = Some(def.id.clone());
|
||||||
|
c.tags = def.tags.clone();
|
||||||
|
c.abilities = def.abilities.clone();
|
||||||
|
|
||||||
|
for (name, value) in &def.base {
|
||||||
|
match toml::from_str::<Attribute>(&format!("\"{name}\"")) {
|
||||||
|
Ok(attr) => c.base.set(attr, *value),
|
||||||
|
// Unbekannter Attributname ist ein Datenfehler und wurde beim
|
||||||
|
// Laden gemeldet — hier schlicht ignorieren.
|
||||||
|
Err(_) => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.health = c.base.get(Attribute::MaxHealth);
|
||||||
|
c.stamina = c.base.get(Attribute::MaxStamina);
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_alive(&self) -> bool {
|
||||||
|
self.health > 0.0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
107
src/game/event.rs
Normal file
107
src/game/event.rs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
//! Events und Trigger — Ticket 08.
|
||||||
|
//!
|
||||||
|
//! Der **Trigger** ist die vierte Wirkungsform: Modifikator rechnet um, Aktion
|
||||||
|
//! wirkt einmal, Faehigkeit bietet an — der Trigger sagt *wann*. Er darf
|
||||||
|
//! ueberall stehen, wo `modifiers` und `actions` stehen.
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use super::action::Action;
|
||||||
|
use super::condition::Condition;
|
||||||
|
use super::creature::CreatureRef;
|
||||||
|
|
||||||
|
/// Zwoelf feste Events, Engine-Vokabular ohne Namensraum. Mod-Events braechten
|
||||||
|
/// nichts, solange niemand sie *ausloesen* kann — das kommt mit Stufe (c).
|
||||||
|
///
|
||||||
|
/// `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)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum EventKind {
|
||||||
|
TurnStarted,
|
||||||
|
AttackHit,
|
||||||
|
DamageTaken,
|
||||||
|
CreatureDied,
|
||||||
|
ItemPickedUp,
|
||||||
|
ItemUsed,
|
||||||
|
FloorEntered,
|
||||||
|
RoomEntered,
|
||||||
|
NpcTalked,
|
||||||
|
QuestCompleted,
|
||||||
|
CombatStarted,
|
||||||
|
CombatEnded,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Befund aus M3:** Ticket 08 hat Events eine Nutzlast gegeben (Schadenstyp,
|
||||||
|
/// Prefab-ID, Menge), aber die Bedingungsgrammatik kann sie **gar nicht
|
||||||
|
/// pruefen** — `Check` sieht nur Wesen, nie das Ereignis. Bis das geklaert ist,
|
||||||
|
/// traegt ein Event nur, was tatsaechlich gelesen wird: wer und gegen wen.
|
||||||
|
/// Nutzlast auf Vorrat waere ein Feld, das niemand ausliest.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Event {
|
||||||
|
pub kind: EventKind,
|
||||||
|
pub source: CreatureRef,
|
||||||
|
pub target: Option<CreatureRef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Event {
|
||||||
|
pub fn new(kind: EventKind, source: CreatureRef) -> Self {
|
||||||
|
Self { kind, source, target: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to(mut self, target: CreatureRef) -> Self {
|
||||||
|
self.target = Some(target);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `{ on = <event>, condition = [...], actions = [...] }` — die vierte
|
||||||
|
/// Wirkungsform. Im Trigger ist `self` immer sein **Traeger**, `target` die
|
||||||
|
/// andere Seite des Events.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Trigger {
|
||||||
|
pub on: EventKind,
|
||||||
|
#[serde(default)]
|
||||||
|
pub condition: Vec<Condition>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub actions: Vec<Action>,
|
||||||
|
/// Wahrscheinlichkeit, dass der Trigger ueberhaupt feuert — „20 % Chance,
|
||||||
|
/// Brennen auszuloesen" braucht sonst ein Script.
|
||||||
|
#[serde(default = "always")]
|
||||||
|
pub chance: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn always() -> f32 {
|
||||||
|
1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trigger_liest_sich_aus_toml() {
|
||||||
|
let t: Trigger = toml::from_str(
|
||||||
|
r#"
|
||||||
|
on = "attack_hit"
|
||||||
|
chance = 0.2
|
||||||
|
condition = [ { check = "has_tag", who = "target", value = "core:undead" } ]
|
||||||
|
actions = [ { action = "damage", amount = 4, damage_type = "fire", target = "target" } ]
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("Trigger-Form aus Ticket 08");
|
||||||
|
|
||||||
|
assert_eq!(t.on, EventKind::AttackHit);
|
||||||
|
assert_eq!(t.condition.len(), 1);
|
||||||
|
assert_eq!(t.actions.len(), 1);
|
||||||
|
assert!((t.chance - 0.2).abs() < f32::EPSILON);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chance_faellt_auf_immer_zurueck() {
|
||||||
|
let t: Trigger = toml::from_str("on = \"turn_started\"").expect("minimal");
|
||||||
|
assert_eq!(t.chance, 1.0);
|
||||||
|
assert!(t.actions.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,10 +43,12 @@ pub struct Room {
|
|||||||
/// Fuer `room_entered` und die Trigger am Prefab (Ticket 08/10).
|
/// Fuer `room_entered` und die Trigger am Prefab (Ticket 08/10).
|
||||||
pub prefab: String,
|
pub prefab: String,
|
||||||
pub rect: Rect,
|
pub rect: Rect,
|
||||||
/// Die `e`-Plaetze aus dem Prefab.
|
/// Die `e`-Plaetze aus dem Prefab — bleiben erhalten, damit die Anzeige
|
||||||
// TODO(M3): wird zu `enemies: Vec<Creature>` — was einzieht, entscheidet
|
/// weiss, wo etwas stand.
|
||||||
// die `creatures`-Zeile der Etage (Ticket 10/14).
|
|
||||||
pub enemy_spawns: Vec<(u16, u16)>,
|
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>,
|
||||||
/// Die `l`-Plaetze. Ticket 16: erst beim **Aufheben** aufgeloest, deshalb
|
/// Die `l`-Plaetze. Ticket 16: erst beim **Aufheben** aufgeloest, deshalb
|
||||||
/// stehen hier Positionen und keine Gegenstaende.
|
/// stehen hier Positionen und keine Gegenstaende.
|
||||||
pub loot: Vec<(u16, u16)>,
|
pub loot: Vec<(u16, u16)>,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
use crate::data::{FloorDef, Prefab, Registries};
|
use crate::data::{FloorDef, Prefab, Registries};
|
||||||
|
|
||||||
|
use super::creature::Creature;
|
||||||
use super::floor::{Floor, Rect, Room, Tile};
|
use super::floor::{Floor, Rect, Room, Tile};
|
||||||
use super::rng::Rng;
|
use super::rng::Rng;
|
||||||
|
|
||||||
@@ -128,10 +129,24 @@ pub fn generate(def: &FloorDef, reg: &Registries, rng: &mut Rng) -> Floor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ein `e` sagt „hier steht ein Gegner", **nicht welcher** — das
|
||||||
|
// entscheidet die Etage (Ticket 10). Nur so laesst sich derselbe Raum
|
||||||
|
// auf Etage 1 und Etage 5 verwenden.
|
||||||
|
let gewichte: Vec<u32> = def.creatures.iter().map(|c| c.weight).collect();
|
||||||
|
let enemies = enemy_spawns
|
||||||
|
.iter()
|
||||||
|
.filter_map(|_| {
|
||||||
|
let i = rng.weighted(&gewichte)?;
|
||||||
|
let cdef = reg.creatures.get(&def.creatures[i].id)?;
|
||||||
|
Some(Creature::from_def(cdef))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
rooms.push(Room {
|
rooms.push(Room {
|
||||||
prefab: p.prefab.clone(),
|
prefab: p.prefab.clone(),
|
||||||
rect: p.rect,
|
rect: p.rect,
|
||||||
enemy_spawns,
|
enemy_spawns,
|
||||||
|
enemies,
|
||||||
loot,
|
loot,
|
||||||
cleared: false,
|
cleared: false,
|
||||||
});
|
});
|
||||||
|
|||||||
834
src/game/mod.rs
834
src/game/mod.rs
@@ -1,15 +1,22 @@
|
|||||||
//! Der Spielkern. **Kennt ratatui nicht** — `tests/boundaries.rs` prueft das.
|
//! Der Spielkern. **Kennt ratatui nicht** — `tests/boundaries.rs` prueft das.
|
||||||
|
|
||||||
|
pub mod action;
|
||||||
pub mod attributes;
|
pub mod attributes;
|
||||||
|
pub mod combat;
|
||||||
pub mod command;
|
pub mod command;
|
||||||
|
pub mod condition;
|
||||||
pub mod creature;
|
pub mod creature;
|
||||||
|
pub mod event;
|
||||||
pub mod floor;
|
pub mod floor;
|
||||||
pub mod generate;
|
pub mod generate;
|
||||||
pub mod rng;
|
pub mod rng;
|
||||||
|
|
||||||
|
pub use action::{Action, ActiveModifier, DamageType, StateField, Target};
|
||||||
pub use attributes::{Attribute, Modifier};
|
pub use attributes::{Attribute, Modifier};
|
||||||
pub use command::{Command, Direction, Outcome};
|
pub use command::{Command, Direction, Outcome};
|
||||||
|
pub use condition::{Check, Condition, Who};
|
||||||
pub use creature::{Creature, CreatureRef, ItemInstance};
|
pub use creature::{Creature, CreatureRef, ItemInstance};
|
||||||
|
pub use event::{Event, EventKind, Trigger};
|
||||||
pub use floor::{Floor, Tile};
|
pub use floor::{Floor, Tile};
|
||||||
pub use rng::Rng;
|
pub use rng::Rng;
|
||||||
|
|
||||||
@@ -19,9 +26,6 @@ use crate::data::Registries;
|
|||||||
/// Etage — es gibt keine Zahl „5" im Code.
|
/// Etage — es gibt keine Zahl „5" im Code.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum RunOutcome {
|
pub enum RunOutcome {
|
||||||
/// TODO(M3): wird gesetzt, sobald es Kampf gibt und der Held sterben kann.
|
|
||||||
/// Der `GameOver`-Screen zeigt beide Enden bereits.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
Died,
|
Died,
|
||||||
Won,
|
Won,
|
||||||
}
|
}
|
||||||
@@ -36,9 +40,13 @@ pub struct Game {
|
|||||||
/// Im Speicherstand, damit Nachladen nicht neu wuerfelt (Ticket 14).
|
/// Im Speicherstand, damit Nachladen nicht neu wuerfelt (Ticket 14).
|
||||||
pub rng: Rng,
|
pub rng: Rng,
|
||||||
pub over: Option<RunOutcome>,
|
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.
|
/// Letzte Meldung fuer die Statuszeile.
|
||||||
pub last: String,
|
pub last: String,
|
||||||
// TODO(M3): `events: Vec<Event>` — die Warteschlange aus Ticket 08.
|
/// Kampflog, die letzten Zeilen.
|
||||||
|
pub journal: Vec<String>,
|
||||||
// TODO(M4): `quests`, `current_exp`, `unspent_points` (Ticket 15 — an
|
// TODO(M4): `quests`, `current_exp`, `unspent_points` (Ticket 15 — an
|
||||||
// `Game`, nicht am `Creature`).
|
// `Game`, nicht am `Creature`).
|
||||||
}
|
}
|
||||||
@@ -61,7 +69,9 @@ impl Game {
|
|||||||
turn: 0,
|
turn: 0,
|
||||||
rng,
|
rng,
|
||||||
over: None,
|
over: None,
|
||||||
|
events: std::collections::VecDeque::new(),
|
||||||
last: String::from("—"),
|
last: String::from("—"),
|
||||||
|
journal: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +82,13 @@ impl Game {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn creature_mut(&mut self, who: CreatureRef) -> &mut Creature {
|
||||||
|
match who {
|
||||||
|
CreatureRef::Player => &mut self.player,
|
||||||
|
CreatureRef::Enemy(i) => &mut self.enemies[i],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Der Lesezugang liegt auf `Game`, nicht auf `Creature` — eine Aura wohnt
|
/// Der Lesezugang liegt auf `Game`, nicht auf `Creature` — eine Aura wohnt
|
||||||
/// beim Traeger und wird beim Auslesen des Ziels eingesammelt (Ticket 01),
|
/// beim Traeger und wird beim Auslesen des Ziels eingesammelt (Ticket 01),
|
||||||
/// ein `Creature` kann seine Attribute also gar nicht allein ausrechnen.
|
/// ein `Creature` kann seine Attribute also gar nicht allein ausrechnen.
|
||||||
@@ -102,61 +119,580 @@ impl Game {
|
|||||||
.iter()
|
.iter()
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO(M3): Affix-Modifikatoren des Exemplars, befristete Modifikatoren
|
let mut value = attributes::stack(c.base.get(attr), from_options.chain(from_equipment), attr);
|
||||||
// am Traeger, und `_against` fuer bedingte Modifikatoren
|
|
||||||
// („+5 Schaden gegen Untote", Ticket 08).
|
// Vierte Quelle: befristete Modifikatoren am Traeger. Sie werden
|
||||||
attributes::stack(c.base.get(attr), from_options.chain(from_equipment), attr)
|
// **nicht** beim Anlegen hineinkopiert — das machte das Lesen billiger
|
||||||
|
// und das Schreiben gefaehrlich (Ticket 07).
|
||||||
|
let (flat, percent) = c
|
||||||
|
.modifiers
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.attribute == attr && m.damage_type.is_none())
|
||||||
|
.fold((0.0, 0.0), |(f, p), m| (f + m.flat, p + m.percent));
|
||||||
|
value = (value + flat) * (1.0 + percent / 100.0);
|
||||||
|
|
||||||
|
// TODO(M4): Affix-Modifikatoren des Exemplars und `_against` fuer
|
||||||
|
// bedingte Modifikatoren („+5 gegen Untote", Ticket 08).
|
||||||
|
value
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resistenz ist **typ-indiziert** und der einzige Minderungsmechanismus
|
||||||
|
/// (Ticket 01). Basis ist null — ein Wesen ohne Resistenz-Modifikatoren
|
||||||
|
/// nimmt vollen Schaden.
|
||||||
|
pub fn resistance(&self, who: CreatureRef, dt: DamageType, reg: &Registries) -> f32 {
|
||||||
|
let c = self.creature(who);
|
||||||
|
let matching = |m: &&Modifier| {
|
||||||
|
m.attribute == Attribute::Resistance && m.damage_type == Some(dt)
|
||||||
|
};
|
||||||
|
|
||||||
|
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()
|
||||||
|
});
|
||||||
|
|
||||||
|
let flat: f32 = from_options
|
||||||
|
.chain(from_equipment)
|
||||||
|
.filter(matching)
|
||||||
|
.map(|m| m.flat)
|
||||||
|
.sum::<f32>()
|
||||||
|
+ c.modifiers
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.attribute == Attribute::Resistance && m.damage_type == Some(dt))
|
||||||
|
.map(|m| m.flat)
|
||||||
|
.sum::<f32>();
|
||||||
|
flat / 100.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Bedingungen — Ticket 08
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
fn who_ref(&self, who: Who, zelf: CreatureRef, target: Option<CreatureRef>) -> Option<CreatureRef> {
|
||||||
|
match who {
|
||||||
|
Who::Zelf => Some(zelf),
|
||||||
|
Who::Target => target,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Liste ist implizit **UND**, jeder Eintrag ueber `not` negierbar.
|
||||||
|
pub fn conditions_hold(
|
||||||
|
&self,
|
||||||
|
conds: &[Condition],
|
||||||
|
zelf: CreatureRef,
|
||||||
|
target: Option<CreatureRef>,
|
||||||
|
reg: &Registries,
|
||||||
|
) -> bool {
|
||||||
|
conds.iter().all(|c| {
|
||||||
|
let Some(subject) = self.who_ref(c.who(), zelf, target) else {
|
||||||
|
// Kein Ziel vorhanden: Die Bedingung kann nicht halten.
|
||||||
|
return c.not;
|
||||||
|
};
|
||||||
|
self.check_holds(&c.check, subject, reg) != c.not
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_holds(&self, check: &Check, subject: CreatureRef, reg: &Registries) -> bool {
|
||||||
|
let c = self.creature(subject);
|
||||||
|
match check {
|
||||||
|
Check::HasTag { value, .. } => c.tags.iter().any(|t| t == value),
|
||||||
|
Check::Attribute { attribute, at_least, below, .. } => {
|
||||||
|
let v = self.attribute(subject, *attribute, None, reg);
|
||||||
|
at_least.is_none_or(|min| v >= min) && below.is_none_or(|max| v < max)
|
||||||
|
}
|
||||||
|
Check::HasItem { item, count, .. } => {
|
||||||
|
let n = c.inventory.iter().filter(|i| &i.def == item).count() as u32;
|
||||||
|
n >= *count
|
||||||
|
}
|
||||||
|
Check::HealthBelow { value, .. } => {
|
||||||
|
let max = self.attribute(subject, Attribute::MaxHealth, None, reg);
|
||||||
|
max > 0.0 && c.health / max < *value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Trigger — gesucht, nicht indiziert (Ticket 08)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/// Sammelt alle Trigger eines Wesens fuer ein Event. Ein Index muesste bei
|
||||||
|
/// jedem An- und Ablegen, jedem Loot und jedem Affix-Wechsel gepflegt
|
||||||
|
/// werden — genau das Invalidierungsproblem, das Ticket 07 vermieden hat.
|
||||||
|
fn triggers_for<'r>(
|
||||||
|
&self,
|
||||||
|
who: CreatureRef,
|
||||||
|
kind: EventKind,
|
||||||
|
reg: &'r Registries,
|
||||||
|
) -> Vec<&'r Trigger> {
|
||||||
|
let c = self.creature(who);
|
||||||
|
let mut out: Vec<&Trigger> = Vec::new();
|
||||||
|
|
||||||
|
if let Some(def) = c.def.as_ref().and_then(|id| reg.creatures.get(id)) {
|
||||||
|
out.extend(def.triggers.iter());
|
||||||
|
}
|
||||||
|
for m in &c.modifiers {
|
||||||
|
if let Some(def) = reg.modifiers.get(&m.id) {
|
||||||
|
out.extend(def.triggers.iter());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TODO(M4): Trigger an Gegenstaenden, Affixen und Charakteroptionen —
|
||||||
|
// sie brauchen dieselben drei Zeilen, sobald die Registries
|
||||||
|
// das Feld tragen.
|
||||||
|
out.retain(|t| t.on == kind);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Aktionen — Ticket 01
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/// Der **eine** Eingang fuer Zustandsaenderungen (Ticket 07).
|
||||||
|
pub fn apply(
|
||||||
|
&mut self,
|
||||||
|
action: &Action,
|
||||||
|
source: CreatureRef,
|
||||||
|
target: CreatureRef,
|
||||||
|
reg: &Registries,
|
||||||
|
) {
|
||||||
|
let victim = match action.target() {
|
||||||
|
Target::Zelf => source,
|
||||||
|
Target::Other | Target::AllEnemies => target,
|
||||||
|
};
|
||||||
|
|
||||||
|
match action {
|
||||||
|
Action::Damage { amount, scale, damage_type, .. } => {
|
||||||
|
// Erst die Trefferfrage. Der Klemmbereich haelt jeden Kampf
|
||||||
|
// entscheidbar: fuenf Prozent Rest in beide Richtungen.
|
||||||
|
let hit = self.attribute(source, Attribute::HitChance, Some(victim), reg);
|
||||||
|
let dodge = self.attribute(victim, Attribute::Dexterity, None, reg);
|
||||||
|
let p = combat::hit_chance(hit, dodge, ®.balance);
|
||||||
|
|
||||||
|
if !self.rng.chance(p) {
|
||||||
|
self.log(format!(
|
||||||
|
"{} verfehlt {}",
|
||||||
|
self.creature(source).name,
|
||||||
|
self.creature(victim).name
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let atk = self.attribute(source, Attribute::Damage, Some(victim), reg);
|
||||||
|
let raw = combat::raw_damage(*amount, *scale, atk);
|
||||||
|
let crit_chance = self.attribute(source, Attribute::CriticalChance, None, reg);
|
||||||
|
let critical = self.rng.chance(crit_chance / 100.0);
|
||||||
|
let crit_factor = self.attribute(source, Attribute::CriticalFactor, None, reg);
|
||||||
|
let resist = self.resistance(victim, *damage_type, reg);
|
||||||
|
|
||||||
|
let dealt = combat::final_damage(raw, critical, crit_factor, resist, ®.balance);
|
||||||
|
self.creature_mut(victim).health -= dealt as f32;
|
||||||
|
|
||||||
|
self.log(format!(
|
||||||
|
"{} trifft {} für {dealt} {}{}",
|
||||||
|
self.creature(source).name,
|
||||||
|
self.creature(victim).name,
|
||||||
|
damage_type.name(),
|
||||||
|
if critical { " (kritisch!)" } else { "" }
|
||||||
|
));
|
||||||
|
|
||||||
|
// `attack_hit` beim Angreifer, `damage_taken` beim Getroffenen —
|
||||||
|
// getrennt, weil dort das flammende Schwert und hier der
|
||||||
|
// Dornenpanzer haengt (Ticket 08/14). Beide feuern **nach** dem
|
||||||
|
// Schaden: Ein Trigger, der Brennen auflegt, ist ein eigener
|
||||||
|
// Schadensfall mit eigener Minderungskette.
|
||||||
|
self.events.push_back(Event::new(EventKind::AttackHit, source).to(victim));
|
||||||
|
self.events.push_back(Event::new(EventKind::DamageTaken, victim).to(source));
|
||||||
|
self.check_death(victim, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
Action::ChangeAttribute { attribute, amount, .. } => {
|
||||||
|
let (max_attr, field) = match attribute {
|
||||||
|
StateField::Health => (Attribute::MaxHealth, 0),
|
||||||
|
StateField::Stamina => (Attribute::MaxStamina, 1),
|
||||||
|
};
|
||||||
|
let max = self.attribute(victim, max_attr, None, reg);
|
||||||
|
let c = self.creature_mut(victim);
|
||||||
|
if field == 0 {
|
||||||
|
c.health = (c.health + amount).clamp(0.0, max);
|
||||||
|
} else {
|
||||||
|
c.stamina = (c.stamina + amount).clamp(0.0, max);
|
||||||
|
}
|
||||||
|
self.check_death(victim, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
Action::ApplyModifier { modifier, .. } => {
|
||||||
|
let Some(def) = reg.modifiers.get(modifier) else {
|
||||||
|
return; // beim Laden bereits gemeldet
|
||||||
|
};
|
||||||
|
let c = self.creature_mut(victim);
|
||||||
|
// Erneutes Auflegen frischt auf, statt zu stapeln.
|
||||||
|
c.modifiers.retain(|m| m.id != *modifier);
|
||||||
|
for change in &def.attributes {
|
||||||
|
c.modifiers.push(ActiveModifier {
|
||||||
|
id: def.id.clone(),
|
||||||
|
attribute: change.attribute,
|
||||||
|
damage_type: change.damage_type,
|
||||||
|
flat: change.flat,
|
||||||
|
percent: change.percent,
|
||||||
|
turns_left: def.turns,
|
||||||
|
name: def.name.clone(),
|
||||||
|
symbol: def.symbol,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.log(format!("{}: {}", self.creature(victim).name, def.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
Action::RemoveModifier { modifier, .. } => {
|
||||||
|
self.creature_mut(victim).modifiers.retain(|m| m.id != *modifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
Action::Flee => {
|
||||||
|
let own = self.attribute(source, Attribute::Dexterity, None, reg);
|
||||||
|
// Gerechnete Werte, nicht Basiswerte — sonst ignoriert die
|
||||||
|
// Flucht jeden Modifikator auf die Geschicklichkeit des Gegners.
|
||||||
|
let fastest = (0..self.enemies.len())
|
||||||
|
.map(|i| {
|
||||||
|
self.attribute(CreatureRef::Enemy(i), Attribute::Dexterity, None, reg)
|
||||||
|
})
|
||||||
|
.fold(0.0f32, f32::max);
|
||||||
|
let p = combat::flee_chance(own, fastest, ®.balance);
|
||||||
|
|
||||||
|
if self.rng.chance(p) {
|
||||||
|
self.enemies.clear();
|
||||||
|
self.log(String::from("Du entkommst."));
|
||||||
|
self.events.push_back(Event::new(EventKind::CombatEnded, CreatureRef::Player));
|
||||||
|
} else {
|
||||||
|
self.log(String::from("Die Flucht misslingt."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_death(&mut self, who: CreatureRef, killer: CreatureRef) {
|
||||||
|
if self.creature(who).is_alive() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let name = self.creature(who).name.clone();
|
||||||
|
self.log(format!("{name} fällt."));
|
||||||
|
self.events.push_back(Event::new(EventKind::CreatureDied, who).to(killer));
|
||||||
|
|
||||||
|
if who == CreatureRef::Player {
|
||||||
|
self.over = Some(RunOutcome::Died);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Events — Warteschlange statt Rekursion (Ticket 08)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/// Arbeitet die Warteschlange leer. **Nach jeder einzelnen Handlung**, nicht
|
||||||
|
/// einmal am Zugende: Sonst schlaegt ein Gegner zu, den ein Dornenpanzer
|
||||||
|
/// eine Handlung zuvor getoetet hat.
|
||||||
|
fn drain_events(&mut self, reg: &Registries) {
|
||||||
|
let mut budget = 100; // Zaehler statt Zyklengraph (Ticket 08)
|
||||||
|
|
||||||
|
while let Some(event) = self.events.pop_front() {
|
||||||
|
budget -= 1;
|
||||||
|
if budget == 0 {
|
||||||
|
self.log(String::from("Zu viele Ereignisse in einem Zug — abgebrochen."));
|
||||||
|
self.events.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Im Trigger ist `self` immer sein Traeger, `target` die andere
|
||||||
|
// Seite des Events (Ticket 08).
|
||||||
|
for carrier in self.participants() {
|
||||||
|
// Die Referenzen binden nur an `reg`, nicht an `self` — deshalb
|
||||||
|
// darf `apply` gleich danach mutieren, ohne zu klonen.
|
||||||
|
let triggers = self.triggers_for(carrier, event.kind, reg);
|
||||||
|
|
||||||
|
for t in triggers {
|
||||||
|
let other = if carrier == event.source { event.target } else { Some(event.source) };
|
||||||
|
if !self.conditions_hold(&t.condition, carrier, other, reg) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if t.chance < 1.0 && !self.rng.chance(t.chance) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for action in &t.actions {
|
||||||
|
let target = other.unwrap_or(carrier);
|
||||||
|
self.apply(action, carrier, target, reg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn participants(&self) -> Vec<CreatureRef> {
|
||||||
|
std::iter::once(CreatureRef::Player)
|
||||||
|
.chain((0..self.enemies.len()).map(CreatureRef::Enemy))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn log(&mut self, line: String) {
|
||||||
|
self.last = line.clone();
|
||||||
|
self.journal.push(line);
|
||||||
|
if self.journal.len() > 60 {
|
||||||
|
self.journal.remove(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Der **eine** Eingang, an dem ein Zug endet (Ticket 11).
|
/// Der **eine** Eingang, an dem ein Zug endet (Ticket 11).
|
||||||
pub fn execute(&mut self, cmd: Command, reg: &Registries) -> Outcome {
|
pub fn execute(&mut self, cmd: Command, reg: &Registries) -> Outcome {
|
||||||
let outcome = match cmd {
|
if self.over.is_some() {
|
||||||
Command::Move(dir) => self.step(dir),
|
return Outcome::Rejected("Der Lauf ist vorbei");
|
||||||
Command::Wait => {
|
|
||||||
self.last = String::from("gewartet");
|
|
||||||
Outcome::TurnPassed
|
|
||||||
}
|
|
||||||
Command::Descend => self.descend(reg),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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
|
|
||||||
|
// Erst pruefen, ob das Kommando ueberhaupt ausfuehrbar ist — ein
|
||||||
|
// abgewiesenes darf keinen Zug kosten und keinen Gegner handeln lassen.
|
||||||
|
if let Some(reason) = self.rejects(&cmd, reg) {
|
||||||
|
return Outcome::Rejected(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. turn_started
|
||||||
|
self.events.push_back(Event::new(EventKind::TurnStarted, CreatureRef::Player));
|
||||||
|
|
||||||
|
// 2. Modifikatoren ticken, Ausdauer regeneriert, Abklingzeiten sinken
|
||||||
|
self.tick(reg);
|
||||||
|
|
||||||
|
// 3. Teilnehmer handeln in Initiativreihenfolge, der Spieler-Command an
|
||||||
|
// seinem Platz — Abbruch, sobald der Held tot ist.
|
||||||
|
for who in self.initiative_order() {
|
||||||
|
if self.over.is_some() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match who {
|
||||||
|
CreatureRef::Player => self.perform(&cmd, reg),
|
||||||
|
CreatureRef::Enemy(i) => self.enemy_acts(i, reg),
|
||||||
|
}
|
||||||
|
// 4. nach *jeder* Handlung leerarbeiten
|
||||||
|
self.drain_events(reg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Tote entfernen — erst **nach** dem Leeren, sonst zeigt ein
|
||||||
|
// creature_died-Trigger auf einen bereits entfernten Gegner.
|
||||||
|
let vorher = self.enemies.len();
|
||||||
|
self.enemies.retain(|e| e.is_alive());
|
||||||
|
if vorher > 0 && self.enemies.is_empty() && self.over.is_none() {
|
||||||
|
self.log(String::from("Der Raum ist geräumt."));
|
||||||
|
self.events.push_back(Event::new(EventKind::CombatEnded, CreatureRef::Player));
|
||||||
|
self.drain_events(reg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6.
|
||||||
|
self.turn += 1;
|
||||||
|
Outcome::TurnPassed
|
||||||
}
|
}
|
||||||
|
|
||||||
fn step(&mut self, dir: Direction) -> Outcome {
|
/// Grund, warum das Kommando gar nicht erst laeuft — oder `None`.
|
||||||
|
fn rejects(&self, cmd: &Command, reg: &Registries) -> Option<&'static str> {
|
||||||
|
let in_combat = !self.enemies.is_empty();
|
||||||
|
match cmd {
|
||||||
|
Command::Move(dir) if in_combat => {
|
||||||
|
let _ = dir;
|
||||||
|
Some("Nicht im Kampf — kämpfe oder fliehe")
|
||||||
|
}
|
||||||
|
Command::Move(dir) => {
|
||||||
|
let (nx, ny) = self.step_target(*dir);
|
||||||
|
(self.floor.tile(nx, ny) == Tile::Wall).then_some("Da ist eine Wand")
|
||||||
|
}
|
||||||
|
Command::Descend if in_combat => Some("Nicht im Kampf"),
|
||||||
|
Command::Descend => (self.floor.tile(self.player_pos.0, self.player_pos.1)
|
||||||
|
!= Tile::Stairs)
|
||||||
|
.then_some("Hier ist keine Treppe"),
|
||||||
|
Command::UseAbility { ability, .. } => {
|
||||||
|
self.ability_block(CreatureRef::Player, ability, reg)
|
||||||
|
}
|
||||||
|
Command::Wait => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alle Faehigkeiten eines Wesens, in stabiler Reihenfolge.
|
||||||
|
///
|
||||||
|
/// `core:attack` und `core:flee` sind **jedem Wesen implizit gewaehrt**
|
||||||
|
/// (Nachtrag aus Ticket 09): Faehigkeiten kommen sonst nur von
|
||||||
|
/// Charakteroptionen und Gegenstaenden, und ein Held ohne Klasse und ohne
|
||||||
|
/// Waffe koennte nicht einmal angreifen.
|
||||||
|
pub fn abilities_of(&self, who: CreatureRef, reg: &Registries) -> Vec<String> {
|
||||||
|
let mut out = vec![String::from("core:attack"), String::from("core:flee")];
|
||||||
|
out.extend(self.creature(who).abilities.iter().cloned());
|
||||||
|
// TODO(M4): Faehigkeiten aus Charakteroptionen und Ausruestung — beide
|
||||||
|
// Registries brauchen dafuer erst ein `abilities`-Feld.
|
||||||
|
out.retain(|id| reg.abilities.contains_key(id));
|
||||||
|
out.dedup();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Warum eine Faehigkeit gesperrt ist (Ticket 14) — oder `None`.
|
||||||
|
pub fn ability_block(
|
||||||
|
&self,
|
||||||
|
who: CreatureRef,
|
||||||
|
ability: &str,
|
||||||
|
reg: &Registries,
|
||||||
|
) -> Option<&'static str> {
|
||||||
|
let Some(def) = reg.abilities.get(ability) else {
|
||||||
|
return Some("Unbekannte Fähigkeit");
|
||||||
|
};
|
||||||
|
let c = self.creature(who);
|
||||||
|
|
||||||
|
if c.stamina < def.cost {
|
||||||
|
return Some("Nicht genug Ausdauer");
|
||||||
|
}
|
||||||
|
if c.cooldowns.get(ability).is_some_and(|t| *t > 0) {
|
||||||
|
return Some("Noch nicht bereit");
|
||||||
|
}
|
||||||
|
let target = (!self.enemies.is_empty()).then_some(CreatureRef::Enemy(0));
|
||||||
|
if !self.conditions_hold(&def.condition, who, target, reg) {
|
||||||
|
return Some("Bedingung nicht erfüllt");
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tick(&mut self, reg: &Registries) {
|
||||||
|
let regen = reg.balance.stamina_regen;
|
||||||
|
for who in self.participants() {
|
||||||
|
let max_stamina = self.attribute(who, Attribute::MaxStamina, None, reg);
|
||||||
|
let c = self.creature_mut(who);
|
||||||
|
c.stamina = (c.stamina + regen).min(max_stamina);
|
||||||
|
for t in c.cooldowns.values_mut() {
|
||||||
|
*t = t.saturating_sub(1);
|
||||||
|
}
|
||||||
|
// Eine Uhr: der Zug. Debuffs ticken auch beim Erkunden (Ticket 01).
|
||||||
|
for m in &mut c.modifiers {
|
||||||
|
m.turns_left = m.turns_left.saturating_sub(1);
|
||||||
|
}
|
||||||
|
c.modifiers.retain(|m| m.turns_left > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reihenfolge innerhalb eines Zuges. Gleichstand geht an den Spieler —
|
||||||
|
/// deterministisch und zu seinen Gunsten (Ticket 14).
|
||||||
|
fn initiative_order(&self) -> Vec<CreatureRef> {
|
||||||
|
let mut order = self.participants();
|
||||||
|
order.sort_by_key(|w| {
|
||||||
|
let init = self.creature(*w).initiative;
|
||||||
|
(-init, matches!(w, CreatureRef::Enemy(_)))
|
||||||
|
});
|
||||||
|
order
|
||||||
|
}
|
||||||
|
|
||||||
|
fn perform(&mut self, cmd: &Command, reg: &Registries) {
|
||||||
|
match cmd {
|
||||||
|
Command::Move(dir) => self.step(*dir),
|
||||||
|
Command::Wait => self.log(String::from("gewartet")),
|
||||||
|
Command::Descend => self.descend(reg),
|
||||||
|
Command::UseAbility { ability, target } => {
|
||||||
|
self.use_ability(CreatureRef::Player, ability, *target, reg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn use_ability(
|
||||||
|
&mut self,
|
||||||
|
who: CreatureRef,
|
||||||
|
ability: &str,
|
||||||
|
target: CreatureRef,
|
||||||
|
reg: &Registries,
|
||||||
|
) {
|
||||||
|
let Some(def) = reg.abilities.get(ability) else { return };
|
||||||
|
|
||||||
|
let c = self.creature_mut(who);
|
||||||
|
c.stamina -= def.cost;
|
||||||
|
if def.cooldown > 0 {
|
||||||
|
c.cooldowns.insert(ability.to_string(), def.cooldown);
|
||||||
|
}
|
||||||
|
|
||||||
|
for action in &def.actions {
|
||||||
|
self.apply(action, who, target, reg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Erster passender **und verfuegbarer** Eintrag der `ai`-Liste gewinnt.
|
||||||
|
/// Passt nichts, setzt der Gegner aus — einer, der die Runde blockiert,
|
||||||
|
/// waere schlimmer als einer, der nichts tut (Ticket 14).
|
||||||
|
fn enemy_acts(&mut self, i: usize, reg: &Registries) {
|
||||||
|
let me = CreatureRef::Enemy(i);
|
||||||
|
if !self.creature(me).is_alive() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(def) = self.creature(me).def.clone().and_then(|id| reg.creatures.get(&id)) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let choice = def.ai.iter().find(|entry| {
|
||||||
|
self.conditions_hold(&entry.condition, me, Some(CreatureRef::Player), reg)
|
||||||
|
&& self.ability_block(me, &entry.ability, reg).is_none()
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(entry) = choice {
|
||||||
|
let ability = entry.ability.clone();
|
||||||
|
self.use_ability(me, &ability, CreatureRef::Player, reg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn step_target(&self, dir: Direction) -> (u16, u16) {
|
||||||
let (x, y) = self.player_pos;
|
let (x, y) = self.player_pos;
|
||||||
let (nx, ny) = match dir {
|
match dir {
|
||||||
Direction::North => (x, y.saturating_sub(1)),
|
Direction::North => (x, y.saturating_sub(1)),
|
||||||
Direction::South => (x, y + 1),
|
Direction::South => (x, y + 1),
|
||||||
Direction::West => (x.saturating_sub(1), y),
|
Direction::West => (x.saturating_sub(1), y),
|
||||||
Direction::East => (x + 1, y),
|
Direction::East => (x + 1, y),
|
||||||
};
|
|
||||||
|
|
||||||
if self.floor.tile(nx, ny) == Tile::Wall {
|
|
||||||
return Outcome::Rejected("Da ist eine Wand");
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Pruefung sitzt in `rejects` — hier wird nur noch bewegt.
|
||||||
|
fn step(&mut self, dir: Direction) {
|
||||||
|
let (nx, ny) = self.step_target(dir);
|
||||||
self.player_pos = (nx, ny);
|
self.player_pos = (nx, ny);
|
||||||
|
|
||||||
// Korridore gehoeren zu keinem Raum — `None` ist ein echter Zustand.
|
// Korridore gehoeren zu keinem Raum — `None` ist ein echter Zustand.
|
||||||
let room = self.floor.room_at(nx, ny);
|
let room = self.floor.room_at(nx, ny);
|
||||||
if room != self.floor.player_room {
|
if room != self.floor.player_room {
|
||||||
self.floor.player_room = room;
|
self.floor.player_room = room;
|
||||||
// TODO(M3): hier feuert `room_entered` (Ticket 08/10) und schiebt
|
if let Some(i) = room {
|
||||||
// die Gegner des Raums nach `Game.enemies`.
|
self.events
|
||||||
|
.push_back(Event::new(EventKind::RoomEntered, CreatureRef::Player));
|
||||||
|
self.enter_room(i);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.last = match self.floor.tile(nx, ny) {
|
self.last = match self.floor.tile(nx, ny) {
|
||||||
Tile::Stairs => String::from("Eine Treppe führt nach unten — '>' zum Absteigen"),
|
Tile::Stairs => String::from("Eine Treppe führt nach unten — '>' zum Absteigen"),
|
||||||
_ => self.room_label(),
|
_ => self.room_label(),
|
||||||
};
|
};
|
||||||
Outcome::TurnPassed
|
}
|
||||||
|
|
||||||
|
/// Ticket 10: **Gegner gehoeren zum Raum, nicht zur Etage.** Ein
|
||||||
|
/// `room_entered` in einen Raum mit lebenden Gegnern verschiebt sie nach
|
||||||
|
/// `Game.enemies` — verschoben, nicht kopiert, sonst gaebe es zwei
|
||||||
|
/// Wahrheiten ueber denselben Gegner.
|
||||||
|
///
|
||||||
|
/// Der Gewinn ist, was dadurch *nicht* gebaut wird: Frei umherlaufende
|
||||||
|
/// Gegner braeuchten Pfadsuche, Sichtlinien und ein Bewegungsbudget.
|
||||||
|
fn enter_room(&mut self, i: usize) {
|
||||||
|
if self.floor.rooms[i].cleared || !self.enemies.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let waiting = std::mem::take(&mut self.floor.rooms[i].enemies);
|
||||||
|
if waiting.is_empty() {
|
||||||
|
self.floor.rooms[i].cleared = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.enemies = waiting;
|
||||||
|
for e in &mut self.enemies {
|
||||||
|
e.initiative = e.base.get(Attribute::Dexterity) as i32 + self.rng.below(6) as i32;
|
||||||
|
}
|
||||||
|
self.player.initiative =
|
||||||
|
self.player.base.get(Attribute::Dexterity) as i32 + self.rng.below(6) as i32;
|
||||||
|
|
||||||
|
let namen: Vec<&str> = self.enemies.iter().map(|e| e.name.as_str()).collect();
|
||||||
|
let zeile = format!("Kampf: {}", namen.join(", "));
|
||||||
|
self.log(zeile);
|
||||||
|
self.events.push_back(Event::new(EventKind::CombatStarted, CreatureRef::Player));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn room_label(&self) -> String {
|
fn room_label(&self) -> String {
|
||||||
@@ -170,23 +706,19 @@ impl Game {
|
|||||||
/// Lauf**. Es gibt keine Zahl „5" im Code — die Engine sucht `[[floors]]`
|
/// Lauf**. Es gibt keine Zahl „5" im Code — die Engine sucht `[[floors]]`
|
||||||
/// mit `number = aktuell + 1`, und findet sie keine, ist Schluss. Ein Mod,
|
/// mit `number = aktuell + 1`, und findet sie keine, ist Schluss. Ein Mod,
|
||||||
/// der Etage 6 ergaenzt, verlaengert das Spiel ohne eine Zeile Engine-Code.
|
/// der Etage 6 ergaenzt, verlaengert das Spiel ohne eine Zeile Engine-Code.
|
||||||
fn descend(&mut self, reg: &Registries) -> Outcome {
|
fn descend(&mut self, reg: &Registries) {
|
||||||
if self.floor.tile(self.player_pos.0, self.player_pos.1) != Tile::Stairs {
|
|
||||||
return Outcome::Rejected("Hier ist keine Treppe");
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(def) = reg.floor_number(self.floor.number + 1) else {
|
let Some(def) = reg.floor_number(self.floor.number + 1) else {
|
||||||
self.over = Some(RunOutcome::Won);
|
self.over = Some(RunOutcome::Won);
|
||||||
self.last = String::from("Du steigst ans Licht.");
|
self.log(String::from("Du steigst ans Licht."));
|
||||||
return Outcome::TurnPassed;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
self.floor = generate::generate(def, reg, &mut self.rng);
|
self.floor = generate::generate(def, reg, &mut self.rng);
|
||||||
self.player_pos = self.floor.entrance();
|
self.player_pos = self.floor.entrance();
|
||||||
self.floor.player_room = self.floor.room_at(self.player_pos.0, self.player_pos.1);
|
self.floor.player_room = self.floor.room_at(self.player_pos.0, self.player_pos.1);
|
||||||
self.enemies.clear();
|
self.enemies.clear();
|
||||||
self.last = format!("Etage {}", self.floor.number);
|
self.log(format!("Etage {}", self.floor.number));
|
||||||
Outcome::TurnPassed
|
self.events.push_back(Event::new(EventKind::FloorEntered, CreatureRef::Player));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +758,224 @@ mod tests {
|
|||||||
assert_eq!(game.over, Some(RunOutcome::Won));
|
assert_eq!(game.over, Some(RunOutcome::Won));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Setzt den Helden in einen Raum mit Gegnern und startet den Kampf.
|
||||||
|
fn im_kampf() -> (Game, Registries) {
|
||||||
|
let (mut game, reg) = zwergenkrieger();
|
||||||
|
game.player.abilities = vec![String::from("core:attack"), String::from("core:flee")];
|
||||||
|
|
||||||
|
// Ersten Raum mit Gegnern finden und betreten.
|
||||||
|
let i = (0..game.floor.rooms.len())
|
||||||
|
.find(|i| !game.floor.rooms[*i].enemies.is_empty())
|
||||||
|
.expect("mindestens ein Raum hat Gegner");
|
||||||
|
game.player_pos = (game.floor.rooms[i].rect.x + 1, game.floor.rooms[i].rect.y + 1);
|
||||||
|
game.floor.player_room = None;
|
||||||
|
game.enter_room(i);
|
||||||
|
|
||||||
|
assert!(!game.enemies.is_empty(), "Kampf muss laufen");
|
||||||
|
(game, reg)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn betreten_eines_raums_verschiebt_die_gegner() {
|
||||||
|
let (mut game, _) = zwergenkrieger();
|
||||||
|
let i = (0..game.floor.rooms.len())
|
||||||
|
.find(|i| !game.floor.rooms[*i].enemies.is_empty())
|
||||||
|
.expect("mindestens ein Raum hat Gegner");
|
||||||
|
let erwartet = game.floor.rooms[i].enemies.len();
|
||||||
|
|
||||||
|
game.floor.player_room = None;
|
||||||
|
game.enter_room(i);
|
||||||
|
|
||||||
|
// **Verschoben, nicht kopiert** — sonst gaebe es zwei Wahrheiten ueber
|
||||||
|
// denselben Gegner (Ticket 10).
|
||||||
|
assert_eq!(game.enemies.len(), erwartet);
|
||||||
|
assert!(game.floor.rooms[i].enemies.is_empty(), "der Raum muss leer sein");
|
||||||
|
|
||||||
|
// Initiative wird beim Betreten gewuerfelt, fuer Held wie Gegner.
|
||||||
|
assert!(game.player.initiative > 0);
|
||||||
|
assert!(game.enemies.iter().all(|e| e.initiative > 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ticket 14: Gleichstand geht an den Spieler — deterministisch und zu
|
||||||
|
/// seinen Gunsten.
|
||||||
|
#[test]
|
||||||
|
fn initiative_bevorzugt_bei_gleichstand_den_spieler() {
|
||||||
|
let (mut game, _) = im_kampf();
|
||||||
|
game.player.initiative = 5;
|
||||||
|
for e in &mut game.enemies {
|
||||||
|
e.initiative = 5;
|
||||||
|
}
|
||||||
|
assert_eq!(game.initiative_order().first(), Some(&CreatureRef::Player));
|
||||||
|
|
||||||
|
// Ein schnellerer Gegner kommt aber wirklich zuerst.
|
||||||
|
game.enemies[0].initiative = 6;
|
||||||
|
assert_eq!(game.initiative_order().first(), Some(&CreatureRef::Enemy(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn angriff_macht_schaden_und_kostet_einen_zug() {
|
||||||
|
let (mut game, reg) = im_kampf();
|
||||||
|
let vorher = game.enemies[0].health;
|
||||||
|
|
||||||
|
let cmd = Command::UseAbility {
|
||||||
|
ability: String::from("core:attack"),
|
||||||
|
target: CreatureRef::Enemy(0),
|
||||||
|
};
|
||||||
|
assert!(matches!(game.execute(cmd, ®), Outcome::TurnPassed));
|
||||||
|
assert_eq!(game.turn, 1);
|
||||||
|
|
||||||
|
// Entweder ist Schaden angekommen oder der Gegner ist ganz weg.
|
||||||
|
let getroffen = game.enemies.is_empty() || game.enemies[0].health < vorher;
|
||||||
|
assert!(getroffen, "der Angriff muss wirken");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ticket 14: Gesperrte Faehigkeit ergibt `Rejected` und **kostet keinen
|
||||||
|
/// Zug** — die Regel steht im Spielkern, nicht in der UI.
|
||||||
|
#[test]
|
||||||
|
fn gesperrte_faehigkeit_kostet_keinen_zug() {
|
||||||
|
let (mut game, reg) = im_kampf();
|
||||||
|
game.player.stamina = 0.0;
|
||||||
|
|
||||||
|
let cmd = Command::UseAbility {
|
||||||
|
ability: String::from("core:flee"),
|
||||||
|
target: CreatureRef::Player,
|
||||||
|
};
|
||||||
|
assert!(matches!(game.execute(cmd, ®), Outcome::Rejected("Nicht genug Ausdauer")));
|
||||||
|
assert_eq!(game.turn, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bewegung_im_kampf_wird_abgewiesen() {
|
||||||
|
let (mut game, reg) = im_kampf();
|
||||||
|
assert!(matches!(
|
||||||
|
game.execute(Command::Move(Direction::North), ®),
|
||||||
|
Outcome::Rejected(_)
|
||||||
|
));
|
||||||
|
assert_eq!(game.turn, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ticket 14: Tote werden **am Ende** von `execute` entfernt, nach dem
|
||||||
|
/// Leerarbeiten der Warteschlange. Innerhalb eines Zuges bleiben Indizes
|
||||||
|
/// stabil.
|
||||||
|
#[test]
|
||||||
|
fn tote_gegner_verschwinden_am_zugende() {
|
||||||
|
let (mut game, reg) = im_kampf();
|
||||||
|
for e in &mut game.enemies {
|
||||||
|
e.health = 1.0;
|
||||||
|
}
|
||||||
|
let cmd = Command::UseAbility {
|
||||||
|
ability: String::from("core:attack"),
|
||||||
|
target: CreatureRef::Enemy(0),
|
||||||
|
};
|
||||||
|
game.execute(cmd, ®);
|
||||||
|
assert!(game.enemies.iter().all(|e| e.is_alive()), "Leichen bleiben nicht liegen");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ticket 14: Stirbt der Held, endet der Lauf — und der Zug bricht ab.
|
||||||
|
#[test]
|
||||||
|
fn heldentod_beendet_den_lauf() {
|
||||||
|
let (mut game, reg) = im_kampf();
|
||||||
|
game.player.health = 1.0;
|
||||||
|
game.player.initiative = -99; // Gegner handeln zuerst
|
||||||
|
|
||||||
|
for _ in 0..40 {
|
||||||
|
if game.over.is_some() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
game.execute(Command::Wait, ®);
|
||||||
|
}
|
||||||
|
assert_eq!(game.over, Some(RunOutcome::Died), "irgendwann trifft ein Gegner");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Trefferformel aus Ticket 14 war beim ersten Anlauf **toter Code** —
|
||||||
|
/// jeder Angriff traf. Nur der Compiler hat es gemeldet („`hit_chance` is
|
||||||
|
/// never used"). Dieser Test haelt sie am Leben.
|
||||||
|
#[test]
|
||||||
|
fn angriffe_koennen_verfehlen() {
|
||||||
|
let (mut game, reg) = im_kampf();
|
||||||
|
let ziel = CreatureRef::Enemy(0);
|
||||||
|
game.enemies[0].health = 10_000.0;
|
||||||
|
|
||||||
|
// Ein unfassbar flinker Gegner: Die Trefferchance faellt auf den Boden
|
||||||
|
// (5 %), es muss also ueber 200 Angriffe hinweg auch Fehlschlaege geben.
|
||||||
|
game.enemies[0].base.set(Attribute::Dexterity, 10_000.0);
|
||||||
|
|
||||||
|
let mut treffer = 0;
|
||||||
|
for _ in 0..200 {
|
||||||
|
let vorher = game.enemies[0].health;
|
||||||
|
game.apply(
|
||||||
|
&Action::Damage {
|
||||||
|
amount: 5.0,
|
||||||
|
scale: 0.0,
|
||||||
|
damage_type: DamageType::Slash,
|
||||||
|
target: Target::Other,
|
||||||
|
},
|
||||||
|
CreatureRef::Player,
|
||||||
|
ziel,
|
||||||
|
®,
|
||||||
|
);
|
||||||
|
if game.enemies[0].health < vorher {
|
||||||
|
treffer += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(treffer > 0, "der Klemmbereich garantiert 5 % Trefferchance");
|
||||||
|
assert!(treffer < 200, "ein Angriff muss verfehlen koennen — sonst ist die Formel tot");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ticket 08: Der Trigger ist die vierte Wirkungsform. `core:poisoned`
|
||||||
|
/// traegt einen `turn_started`-Trigger, der Schaden macht — ohne Script.
|
||||||
|
#[test]
|
||||||
|
fn befristeter_modifikator_tickt_und_verfaellt() {
|
||||||
|
let (mut game, reg) = zwergenkrieger();
|
||||||
|
let p = CreatureRef::Player;
|
||||||
|
|
||||||
|
game.apply(
|
||||||
|
&Action::ApplyModifier { modifier: String::from("core:poisoned"), target: Target::Zelf },
|
||||||
|
p,
|
||||||
|
p,
|
||||||
|
®,
|
||||||
|
);
|
||||||
|
assert!(!game.player.modifiers.is_empty(), "Gift muss aufliegen");
|
||||||
|
|
||||||
|
// Der Modifikator senkt `damage` um 15 % — vierte Quelle in `attribute`.
|
||||||
|
let vergiftet = game.attribute(p, Attribute::Damage, None, ®);
|
||||||
|
game.player.modifiers.clear();
|
||||||
|
let sauber = game.attribute(p, Attribute::Damage, None, ®);
|
||||||
|
assert!(vergiftet < sauber, "Gift muss den Schaden senken: {vergiftet} < {sauber}");
|
||||||
|
|
||||||
|
// Und er verfaellt: vier Zuege, dann ist er weg.
|
||||||
|
game.apply(
|
||||||
|
&Action::ApplyModifier { modifier: String::from("core:poisoned"), target: Target::Zelf },
|
||||||
|
p,
|
||||||
|
p,
|
||||||
|
®,
|
||||||
|
);
|
||||||
|
let leben_vorher = game.player.health;
|
||||||
|
for _ in 0..6 {
|
||||||
|
game.execute(Command::Wait, ®);
|
||||||
|
}
|
||||||
|
assert!(game.player.modifiers.is_empty(), "nach sechs Zuegen ist das Gift weg");
|
||||||
|
assert!(game.player.health < leben_vorher, "der Trigger muss geschadet haben");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resistenz ist typ-indiziert und kommt nur aus Modifikatoren (Ticket 01).
|
||||||
|
#[test]
|
||||||
|
fn resistenz_wirkt_typweise() {
|
||||||
|
let (mut game, reg) = zwergenkrieger();
|
||||||
|
let p = CreatureRef::Player;
|
||||||
|
|
||||||
|
assert_eq!(game.resistance(p, DamageType::Fire, ®), 0.0);
|
||||||
|
game.apply(
|
||||||
|
&Action::ApplyModifier { modifier: String::from("core:warded"), target: Target::Zelf },
|
||||||
|
p,
|
||||||
|
p,
|
||||||
|
®,
|
||||||
|
);
|
||||||
|
assert!((game.resistance(p, DamageType::Fire, ®) - 0.4).abs() < 0.001);
|
||||||
|
// Der Schutz gilt nur fuer die genannten Typen.
|
||||||
|
assert_eq!(game.resistance(p, DamageType::Slash, ®), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn absteigen_ohne_treppe_kostet_keinen_zug() {
|
fn absteigen_ohne_treppe_kostet_keinen_zug() {
|
||||||
let (mut game, reg) = zwergenkrieger();
|
let (mut game, reg) = zwergenkrieger();
|
||||||
|
|||||||
162
src/ui/app.rs
162
src/ui/app.rs
@@ -11,7 +11,7 @@ use super::grid::GridWidget;
|
|||||||
use super::screen::{Response, Screen};
|
use super::screen::{Response, Screen};
|
||||||
use super::theme;
|
use super::theme;
|
||||||
use crate::data::Registries;
|
use crate::data::Registries;
|
||||||
use crate::game::{Attribute, CreatureRef, Game, Outcome, RunOutcome};
|
use crate::game::{Attribute, CreatureRef, DamageType, Game, Outcome, RunOutcome};
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
/// `game` und `registries` sind **getrennte Felder**, damit die disjunkten
|
/// `game` und `registries` sind **getrennte Felder**, damit die disjunkten
|
||||||
@@ -24,6 +24,28 @@ pub struct App {
|
|||||||
rejected: Option<&'static str>,
|
rejected: Option<&'static str>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
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 { .. }));
|
||||||
|
|
||||||
|
if in_combat && !shows_combat {
|
||||||
|
self.screens.push(Screen::Combat { pick: 0 });
|
||||||
|
} else if !in_combat {
|
||||||
|
self.screens.retain(|s| !matches!(s, Screen::Combat { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.game.over.is_some()
|
||||||
|
&& !self.screens.iter().any(|s| matches!(s, Screen::GameOver))
|
||||||
|
{
|
||||||
|
self.screens.push(Screen::GameOver);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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, log_name: Option<String>) -> std::io::Result<()> {
|
||||||
let mut app = App {
|
let mut app = App {
|
||||||
game,
|
game,
|
||||||
@@ -45,9 +67,12 @@ pub fn run(game: Game, registries: Registries, warnings: usize, log_name: Option
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(screen) = app.screens.last_mut() else { break Ok(()) };
|
// Die Screens liegen in `app.screens`, gelesen wird aus `app.game`
|
||||||
|
// und `app.registries` — drei getrennte Felder, disjunkte Borrows.
|
||||||
|
let App { game, registries, screens, .. } = &mut app;
|
||||||
|
let Some(screen) = screens.last_mut() else { break Ok(()) };
|
||||||
|
|
||||||
match screen.handle_key(key.code) {
|
match screen.handle_key(key.code, game, registries) {
|
||||||
Response::None => {}
|
Response::None => {}
|
||||||
Response::Pop => {
|
Response::Pop => {
|
||||||
app.screens.pop();
|
app.screens.pop();
|
||||||
@@ -67,10 +92,9 @@ pub fn run(game: Game, registries: Registries, warnings: usize, log_name: Option
|
|||||||
app.screens.pop();
|
app.screens.pop();
|
||||||
}
|
}
|
||||||
// Ticket 10/14: Der Lauf endet ueber `Game`, nicht ueber
|
// Ticket 10/14: Der Lauf endet ueber `Game`, nicht ueber
|
||||||
// die UI — die reagiert nur darauf.
|
// die UI — die reagiert nur darauf. Dasselbe gilt fuer den
|
||||||
if app.game.over.is_some() {
|
// Kampf: `Game.enemies` ist die Wahrheit, der Screen folgt.
|
||||||
app.screens.push(Screen::GameOver);
|
app.sync_screens();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Response::Quit => break Ok(()),
|
Response::Quit => break Ok(()),
|
||||||
}
|
}
|
||||||
@@ -86,6 +110,7 @@ fn draw(frame: &mut Frame, app: &App) {
|
|||||||
match screen {
|
match screen {
|
||||||
Screen::MainMenu => draw_main_menu(frame, area, app),
|
Screen::MainMenu => draw_main_menu(frame, area, app),
|
||||||
Screen::Explore => draw_explore(frame, area, app),
|
Screen::Explore => draw_explore(frame, area, app),
|
||||||
|
Screen::Combat { pick } => draw_combat(frame, area, app, *pick),
|
||||||
Screen::CharacterSheet => draw_sheet(frame, area, app),
|
Screen::CharacterSheet => draw_sheet(frame, area, app),
|
||||||
Screen::GameOver => draw_game_over(frame, area, app),
|
Screen::GameOver => draw_game_over(frame, area, app),
|
||||||
}
|
}
|
||||||
@@ -143,6 +168,104 @@ fn draw_explore(frame: &mut Frame, area: Rect, app: &App) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Menue-Kampf (Charter: Dungeon Master / Etrian Odyssey). Links die Gegner mit
|
||||||
|
/// Ziffern, rechts die Faehigkeiten mit Kosten und **Sperrgrund** — der kommt
|
||||||
|
/// aus dem Spielkern, nicht aus der UI (Ticket 14).
|
||||||
|
fn draw_combat(frame: &mut Frame, area: Rect, app: &App, pick: usize) {
|
||||||
|
let block = Block::bordered().title(" Kampf ");
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
|
let [links, rechts] = Layout::horizontal([Constraint::Percentage(45), Constraint::Min(0)])
|
||||||
|
.areas(inner);
|
||||||
|
let [gegner_area, log_area] =
|
||||||
|
Layout::vertical([Constraint::Percentage(45), Constraint::Min(0)]).areas(links);
|
||||||
|
|
||||||
|
// Gegner mit Zielziffer und Lebensbalken.
|
||||||
|
let mut zeilen = Vec::new();
|
||||||
|
for (i, e) in app.game.enemies.iter().enumerate() {
|
||||||
|
let max = app
|
||||||
|
.game
|
||||||
|
.attribute(CreatureRef::Enemy(i), Attribute::MaxHealth, None, &app.registries);
|
||||||
|
let anteil = if max > 0.0 { (e.health / max).clamp(0.0, 1.0) } else { 0.0 };
|
||||||
|
let balken = "█".repeat((anteil * 10.0).round() as usize);
|
||||||
|
|
||||||
|
let marken: String = e.modifiers.iter().map(|m| m.symbol).collect();
|
||||||
|
zeilen.push(Line::from(format!(
|
||||||
|
"{} {:<14} {:<10} {:>4.0}/{:<4.0} {marken}",
|
||||||
|
i + 1,
|
||||||
|
e.name,
|
||||||
|
balken,
|
||||||
|
e.health.max(0.0),
|
||||||
|
max
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if zeilen.is_empty() {
|
||||||
|
zeilen.push(Line::from("—").style(Style::new().fg(Color::DarkGray)));
|
||||||
|
}
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(zeilen).block(Block::bordered().title(" Gegner ")),
|
||||||
|
gegner_area,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Kampflog, jüngste Zeile unten.
|
||||||
|
let log: Vec<Line> = app
|
||||||
|
.game
|
||||||
|
.journal
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.take(log_area.height.saturating_sub(2) as usize)
|
||||||
|
.rev()
|
||||||
|
.map(|l| Line::from(l.as_str()))
|
||||||
|
.collect();
|
||||||
|
frame.render_widget(Paragraph::new(log).block(Block::bordered().title(" Verlauf ")), log_area);
|
||||||
|
|
||||||
|
// Faehigkeiten.
|
||||||
|
let mut zeilen = Vec::new();
|
||||||
|
for (i, id) in app.game.abilities_of(CreatureRef::Player, &app.registries).iter().enumerate() {
|
||||||
|
let Some(def) = app.registries.abilities.get(id) else { continue };
|
||||||
|
let block = app.game.ability_block(CreatureRef::Player, id, &app.registries);
|
||||||
|
|
||||||
|
let zeiger = if i == pick { "›" } else { " " };
|
||||||
|
let text = format!(
|
||||||
|
"{zeiger} {:<16} {:>3} Ausd. {}",
|
||||||
|
def.name,
|
||||||
|
def.cost as u32,
|
||||||
|
block.unwrap_or("")
|
||||||
|
);
|
||||||
|
let farbe = match (block.is_some(), i == pick) {
|
||||||
|
(true, _) => Color::DarkGray,
|
||||||
|
(false, true) => Color::Yellow,
|
||||||
|
(false, false) => Color::Reset,
|
||||||
|
};
|
||||||
|
zeilen.push(Line::from(text).style(Style::new().fg(farbe)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let p = &app.game.player;
|
||||||
|
let max_health =
|
||||||
|
app.game.attribute(CreatureRef::Player, Attribute::MaxHealth, None, &app.registries);
|
||||||
|
let max_stamina =
|
||||||
|
app.game.attribute(CreatureRef::Player, Attribute::MaxStamina, None, &app.registries);
|
||||||
|
zeilen.push(Line::from(""));
|
||||||
|
zeilen.push(Line::from(format!(
|
||||||
|
"{} {:.0}/{:.0} Leben {:.0}/{:.0} Ausdauer",
|
||||||
|
p.name, p.health.max(0.0), max_health, p.stamina, max_stamina
|
||||||
|
)));
|
||||||
|
if !p.modifiers.is_empty() {
|
||||||
|
let namen: Vec<&str> = p.modifiers.iter().map(|m| m.name.as_str()).collect();
|
||||||
|
zeilen.push(Line::from(namen.join(", ")).style(Style::new().fg(Color::Green)));
|
||||||
|
}
|
||||||
|
zeilen.push(
|
||||||
|
Line::from("↑↓ wählen · 1-9 Ziel · Enter erstes Ziel · . warten · c Bogen")
|
||||||
|
.style(Style::new().fg(Color::DarkGray)),
|
||||||
|
);
|
||||||
|
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(zeilen).block(Block::bordered().title(" Fähigkeiten ")),
|
||||||
|
rechts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Der Lauf wird am Ende **erzaehlt** — das ist der Moment, fuer den man die
|
/// Der Lauf wird am Ende **erzaehlt** — das ist der Moment, fuer den man die
|
||||||
/// Etagen gespielt hat, und der Grund, warum Ticket 14 hier keinen Einzeiler im
|
/// Etagen gespielt hat, und der Grund, warum Ticket 14 hier keinen Einzeiler im
|
||||||
/// Hauptmenue wollte.
|
/// Hauptmenue wollte.
|
||||||
@@ -186,8 +309,11 @@ fn draw_sheet(frame: &mut Frame, area: Rect, app: &App) {
|
|||||||
|
|
||||||
// Attribute: jeder Wert kommt aus Game::attribute, laeuft also durch alle
|
// Attribute: jeder Wert kommt aus Game::attribute, laeuft also durch alle
|
||||||
// Quellen — das ist der Punkt des ganzen Durchstichs.
|
// Quellen — das ist der Punkt des ganzen Durchstichs.
|
||||||
|
// `resistance` faellt heraus: Ein einzelner Wert dafuer existiert nicht, es
|
||||||
|
// sind sieben (Ticket 01). Sie stehen rechts als eigene Zeile.
|
||||||
let rows: Vec<Row> = Attribute::ALL
|
let rows: Vec<Row> = Attribute::ALL
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter(|a| **a != Attribute::Resistance)
|
||||||
.map(|a| {
|
.map(|a| {
|
||||||
let base = app.game.player.base.get(*a);
|
let base = app.game.player.base.get(*a);
|
||||||
let total =
|
let total =
|
||||||
@@ -233,6 +359,28 @@ fn draw_sheet(frame: &mut Frame, area: Rect, app: &App) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resistenz ist typ-indiziert — nur was von null abweicht, ist erwaehnenswert.
|
||||||
|
let widerstaende: Vec<String> = DamageType::ALL
|
||||||
|
.iter()
|
||||||
|
.filter_map(|dt| {
|
||||||
|
let r = app.game.resistance(CreatureRef::Player, *dt, &app.registries);
|
||||||
|
(r.abs() > f32::EPSILON).then(|| format!("{} {:+.0}%", dt.name(), r * 100.0))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if !widerstaende.is_empty() {
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
lines.push(Line::from("Resistenz".bold()));
|
||||||
|
lines.push(Line::from(format!(" {}", widerstaende.join(", "))));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !app.game.player.modifiers.is_empty() {
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
lines.push(Line::from("Zustände".bold()));
|
||||||
|
for m in &app.game.player.modifiers {
|
||||||
|
lines.push(Line::from(format!(" {} ({} Züge)", m.name, m.turns_left)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
lines.push(Line::from(""));
|
lines.push(Line::from(""));
|
||||||
lines.push(Line::from("Esc zurück").style(Style::new().fg(Color::DarkGray)));
|
lines.push(Line::from("Esc zurück").style(Style::new().fg(Color::DarkGray)));
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
use ratatui::crossterm::event::KeyCode;
|
use ratatui::crossterm::event::KeyCode;
|
||||||
|
|
||||||
use crate::game::{Command, Direction};
|
use crate::data::Registries;
|
||||||
|
use crate::game::{Command, CreatureRef, Direction, Game};
|
||||||
|
|
||||||
/// Popups sind gewoehnliche Screens, die sich beim Rendern als Overlay
|
/// Popups sind gewoehnliche Screens, die sich beim Rendern als Overlay
|
||||||
/// ausweisen — ein gemeinsamer `Vec<Screen>` statt `current` plus
|
/// ausweisen — ein gemeinsamer `Vec<Screen>` statt `current` plus
|
||||||
@@ -10,6 +11,11 @@ use crate::game::{Command, Direction};
|
|||||||
pub enum Screen {
|
pub enum Screen {
|
||||||
MainMenu,
|
MainMenu,
|
||||||
Explore,
|
Explore,
|
||||||
|
/// Menue-Kampf (Charter): Faehigkeitsliste, Zielwahl mit den Zifferntasten.
|
||||||
|
Combat {
|
||||||
|
/// Cursor in der Faehigkeitsliste.
|
||||||
|
pick: usize,
|
||||||
|
},
|
||||||
CharacterSheet,
|
CharacterSheet,
|
||||||
/// Vollbild, kein Overlay. Bewusst nicht die lazy Variante mit einer
|
/// Vollbild, kein Overlay. Bewusst nicht die lazy Variante mit einer
|
||||||
/// Meldungszeile im Hauptmenue: Ein Abstiegs-Roguelike lebt davon, dass der
|
/// Meldungszeile im Hauptmenue: Ein Abstiegs-Roguelike lebt davon, dass der
|
||||||
@@ -37,7 +43,12 @@ impl Screen {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle_key(&mut self, key: KeyCode) -> Response {
|
/// **Befund aus M3:** Ticket 11 hatte `handle_key(&mut self, key)` ohne
|
||||||
|
/// Kontext festgelegt. Der Kampf-Screen muss aber wissen, wie viele
|
||||||
|
/// Faehigkeiten es gibt, um den Cursor zu klemmen, und welche der Cursor
|
||||||
|
/// meint. Lesender Zugriff genuegt — die Regel „ein Screen fasst `Game`
|
||||||
|
/// nicht an" bleibt, weil nur `Response::Command` etwas aendert.
|
||||||
|
pub fn handle_key(&mut self, key: KeyCode, game: &Game, reg: &Registries) -> Response {
|
||||||
match self {
|
match self {
|
||||||
Screen::MainMenu => match key {
|
Screen::MainMenu => match key {
|
||||||
KeyCode::Enter => Response::Push(Screen::Explore),
|
KeyCode::Enter => Response::Push(Screen::Explore),
|
||||||
@@ -68,6 +79,45 @@ impl Screen {
|
|||||||
_ => Response::None,
|
_ => Response::None,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
Screen::Combat { pick } => {
|
||||||
|
let abilities = game.abilities_of(CreatureRef::Player, reg);
|
||||||
|
if abilities.is_empty() {
|
||||||
|
return Response::None;
|
||||||
|
}
|
||||||
|
*pick = (*pick).min(abilities.len() - 1);
|
||||||
|
|
||||||
|
match key {
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
|
*pick = pick.saturating_sub(1);
|
||||||
|
Response::None
|
||||||
|
}
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
|
*pick = (*pick + 1).min(abilities.len() - 1);
|
||||||
|
Response::None
|
||||||
|
}
|
||||||
|
// Ziel ueber die Zifferntasten; ohne Gegner (Flucht,
|
||||||
|
// Selbstheilung) zaehlt der Held selbst als Ziel.
|
||||||
|
KeyCode::Enter | KeyCode::Char('1'..='9') => {
|
||||||
|
let index = match key {
|
||||||
|
KeyCode::Char(c @ '1'..='9') => (c as u8 - b'1') as usize,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
let target = if game.enemies.is_empty() {
|
||||||
|
CreatureRef::Player
|
||||||
|
} else {
|
||||||
|
CreatureRef::Enemy(index.min(game.enemies.len() - 1))
|
||||||
|
};
|
||||||
|
Response::Command(Command::UseAbility {
|
||||||
|
ability: abilities[*pick].clone(),
|
||||||
|
target,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
KeyCode::Char('.') => Response::Command(Command::Wait),
|
||||||
|
KeyCode::Char('c') => Response::Push(Screen::CharacterSheet),
|
||||||
|
_ => Response::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Screen::CharacterSheet => match key {
|
Screen::CharacterSheet => match key {
|
||||||
KeyCode::Esc | KeyCode::Char('c') | KeyCode::Char('q') => Response::Pop,
|
KeyCode::Esc | KeyCode::Char('c') | KeyCode::Char('q') => Response::Pop,
|
||||||
_ => Response::None,
|
_ => Response::None,
|
||||||
|
|||||||
Reference in New Issue
Block a user