Files
rustkeep/.scratch/rustkeep-1.0/issues/14-kampfformeln-und-rundenablauf.md

224 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 14 — Kampfformeln und Rundenablauf
Type: grilling
Status: resolved
Blocked by: 01
## Question
Wie läuft ein Kampf ab, und mit welchen Formeln?
Der Attributkatalog aus 01 liefert die Zutaten (`damage`, `hit_chance`, `critical_chance`,
`critical_factor`, `resistance`, `dexterity`, `luck`). Wie sie zusammenwirken, ist offen:
- **Reihenfolge**: Was bestimmt, wer zuerst handelt — `dexterity`, ein Initiativwurf, oder immer der
Spieler zuerst? Ändert sich das während des Kampfes?
- **Trefferformel**: Wie berechnet sich, ob ein Angriff trifft? Ist `hit_chance` gegen Ausweichen
gerechnet, oder trifft immer und `dexterity` mindert nur den Schaden?
- **Schadensformel**: In welcher Reihenfolge greifen Waffe, `damage`-Modifikatoren, Krit und
`resistance`? Ist Resistenz prozentual oder flach, und gibt es eine Ober- oder Untergrenze?
- **Kritische Treffer**: Wie speist `luck` in `critical_chance`? Multipliziert `critical_factor` den
Rohschaden oder den geminderten?
- **Aktionen pro Zug**: Genau eine, oder gibt es Ausdauer als Budget? Wozu dient `max_stamina` sonst?
- **Fähigkeiten** (nachgetragen aus Ticket 02): Was kostet eine Fähigkeit — Ausdauer, ein eigener
Ressourcenpool, Ladungen? Gibt es Abklingzeiten, und werden sie in Zügen gezählt? Wann ist eine
Fähigkeit gesperrt, und woran sieht der Spieler das? Ticket 02 hat Fähigkeiten als Registry
festgelegt, ihre Kampfsemantik aber ausdrücklich hierher delegiert.
- **Flucht**: Möglich, mit welcher Chance, mit welchen Kosten?
- **Zufall**: Schadensstreuung ja oder nein, und woher kommt der Zufallsgenerator — braucht es einen
Seed im Speicherstand?
Ergebnis: die Formeln als Pseudocode plus der Ablauf eines Zuges von Eingabe bis Ergebnis.
## Vorgabe aus Ticket 07
Der Aktionskatalog und die Ziel-Auflösung (`self` / `target` / `all_enemies` aus 01) gehören hierher.
Alles läuft durch `Game::apply(action, source: CreatureRef, target: CreatureRef, reg)`, gelesen wird
über `Game::attribute(who, attr, reg)`.
Eine Frage fällt zusätzlich an, die 07 nur benennen konnte: **Wann werden tote Gegner aufgeräumt?**
`CreatureRef::Enemy(i)` ist ein Index in `Game.enemies` und überlebt das Entfernen aus der `Vec`
nicht — eine Aktion, die mitten im Zug einen Gegner tötet, verschiebt die Adressen aller
nachfolgenden. Der Rundenablauf muss festlegen, ob am Zugende, am Rundenende oder gar nicht
kompaktiert wird (Alternative: Tote bleiben mit einem Flag stehen).
## Answer
Begriffe im [Glossar](../../../CONTEXT.md). **Zahlen gehören nicht hierher** — die Map hält Balancing
getrennt; hier stehen Formeln und Ablauf.
### Rundenablauf
**Ein Zug = Spielerhandlung plus alle Gegnerreaktionen.** Nicht jede Handlung ein eigener Zug: Bei drei
Gegnern tickte ein Gift-Debuff sonst viermal pro Runde, und ein Tastendruck erzeugte vier Züge — das
bräche die Zusage aus 11, die Ticket 12 gerade gemessen hat.
Reihenfolge über **Initiative**, einmal beim Betreten des Kampfes aus `dexterity` plus Wurf gewürfelt
und als `i32` **am Wesen** gehalten. Ausdrücklich *nicht* als Reihenfolgeliste aus `CreatureRef`: Die
wäre eine zweite Indexliste, die beim Tod eines Gegners genauso ungültig wird wie die erste. Ein
Zahlenwert am Wesen verschwindet mit ihm, die Sortierung stimmt weiter. Gleichstand geht an den
Spieler — deterministisch und zu seinen Gunsten.
`dexterity` speist damit Initiative *und* Ausweichen. Absicht, aber 15 sollte es beim Balancing wissen.
```
Game::execute(cmd, reg):
1. turn_started (+ combat_started, falls der Kampf gerade beginnt) in die Warteschlange
2. befristete Modifikatoren ticken, Ausdauer regeneriert, Abklingzeiten sinken
3. Teilnehmer handeln in Initiativreihenfolge, Spieler-Command an seinem Platz
-> Abbruch, sobald der Held tot ist
4. nach JEDER einzelnen Handlung die Warteschlange leerarbeiten (bis leer oder 100)
5. retain auf tote Gegner, danach ggf. combat_ended
6. turn += 1
```
Schritt 4 nach jeder Handlung statt einmal am Zugende: Sonst schlägt ein Gegner zu, den ein
Dornenpanzer eine Handlung zuvor getötet hat. Schritt 5 erst **nach** dem Leeren: Sonst zeigt ein
Trigger auf `creature_died` auf einen bereits entfernten Gegner. Innerhalb eines Zuges müssen Indizes
stabil bleiben — über Zuggrenzen hinweg hält ohnehin niemand ein `CreatureRef`, die UI liest neu.
Genau **eine** Handlung pro Zug, kein Ausdauer-Budget. `max_stamina` ist die Ressource für
Fähigkeiten — das beantwortet die Frage „wozu sonst" aus dem Ticketkörper.
Stirbt der Held mitten im Zug, bricht der Zug ab (laufende Handlung samt Warteschlange noch zu Ende).
Zwei weitere Gegner, die auf eine Leiche einschlagen, erzeugen nur Kampflog und Trigger auf ein Wesen,
das die Runde nicht mehr erlebt.
### Formeln
```
p_treffer = clamp(hit_chance(A, gegen=B) - dexterity(B) * AUSWEICH, 0.05, 0.95)
roh = attribute(damage, A, gegen=B) # 01-Stapelung, bedingte Modifikatoren inklusive
krit = rand() < clamp(critical_chance(A), 0.0, 1.0)
roh = roh * critical_factor(A) falls krit
r = clamp(resistance(B, typ), -1.0, 0.8)
final = max(1, round(roh * (1.0 - r)))
p_flucht = clamp(0.5 + (dexterity(Held) - dexterity(schnellster Gegner)) * FLUCHT, 0.1, 0.9)
```
**Die Frage aus dem Ticketkörper löst sich auf.** Ob `critical_factor` den Rohschaden oder den
geminderten multipliziert, ist mathematisch belanglos: `roh × kf × (1−r)` ist dasselbe wie
`roh × (1−r) × kf`. Festzulegen war nur, dass beide multiplikativ *bleiben*.
Drei Klemmbereiche, jeder mit Grund:
- **Resistenz bei `0.8`** — bei `1.0` wäre ein Wesen immun, und Immunität ist keine Zahl, sondern ein
Spielabbruch. Nach unten bis `-1.0`, damit Verwundbarkeit ausdrückbar bleibt („Untote nehmen
doppelten heiligen Schaden").
- **`max(1, …)`** — ein Treffer macht nie null, sonst gibt es Kämpfe, die rechnerisch nie enden.
- **`0.05 / 0.95` beim Treffen** — sonst wird ein hochgezüchteter Verteidiger unangreifbar und ein
hochgezüchteter Angreifer unfehlbar. Beides Sackgassen, die man erst auf Etage 5 bemerkt.
Keine Schadensstreuung für 1.0: Der Krit liefert die Varianz bereits, Streuung wäre ein zweiter
Zufallsknopf ohne eigene Entscheidung dahinter.
### Zufall
Eigener xorshift64* — acht Zeilen statt einer Dependency. Der Seed liegt als Feld in `Game` und damit
**im Speicherstand**.
Das ist keine Implementierungsfrage, sondern eine Spielentscheidung: **Nachladen würfelt nicht neu.**
Derselbe Angriff führt nach dem Laden zum selben Ergebnis, Save-Scumming läuft ins Leere. Ein
Abstiegs-Roguelike lebt von Entscheidungen mit Konsequenzen.
### Fähigkeiten — die Bestellung aus Ticket 02
```toml
[[abilities]]
id = "core:power_strike"
name = "Wuchtschlag"
symbol = "!"
color = "red"
cost = 15 # Ausdauer
cooldown = 3 # Zuege
condition = [ … ] # optional, Grammatik aus 08
actions = [ { action = "damage", scale = 1.5, damage_type = "blunt", target = "target" } ]
```
**Nur Ausdauer, kein zweiter Ressourcenpool.** Mana wäre ein zweites Attribut, eine zweite
Regeneration, eine zweite Anzeige und eine zweite Sperrregel. Die Decke: Ein Mod, der Mana will,
bräuchte ein Engine-Attribut — ein Nachtrag an 01, kein Problem, das ein Modder selbst lösen kann.
**Abklingzeit in Zügen**, als `cooldowns: HashMap<AbilityId, u32>` am `Creature`. Sie als befristete
Modifikatoren zu tarnen wäre clever und genau die Sorte Cleverness, die man um drei Uhr nachts
entziffert.
**Gesperrt heißt**: Ausdauer < `cost`, oder `cooldown > 0`, oder `condition` unerfüllt. Auslösen ergibt
dann `Outcome::Rejected("Nicht genug Ausdauer")` aus Ticket 12 und **kostet keinen Zug**. Die Regel
steht im Spielkern, nicht in der UI — sonst kennt ein Script sie nicht.
**Ausdauer regeneriert pro Zug** um einen festen Betrag. Das gibt `Wait` aus Ticket 11 seinen Zweck und
macht Ausdauer zu einer Entscheidung statt zu einem Countdown.
Eine misslungene Flucht ist übrigens `TurnPassed`, keine Zurückweisung: Der Versuch hat stattgefunden,
die Gegner haben gehandelt. `Rejected` bleibt dem Fall vorbehalten, dass gar nichts passiert ist.
### Gegner — die achte Registry
Beim Aufschreiben der KI fiel eine Lücke auf, die seit 02 offen lag: **Wesen stehen in keiner
Registry.** 02 hatte fünf, 07 ergänzte Affixe, 08 Quests — woher Gegnerdefinitionen kommen, hat nie
jemand gesagt, obwohl 10 sie in Räume setzen und 16 Beute an sie hängen muss.
```toml
[[creatures]]
id = "core:skeleton"
name = "Skelett"
symbol = "s"
color = "white"
tags = ["core:undead"]
base = { max_health = 30, damage = 6, hit_chance = 0.7, dexterity = 3 }
abilities = ["core:attack", "core:bone_throw"]
ai = [
{ condition = [ { check = "attribute", who = "self", attribute = "health", below = 0.3 } ],
ability = "core:flee" },
{ ability = "core:attack" }, # ohne condition = greift immer
]
```
`ai` ist eine **geordnete Liste**, der erste passende *und verfügbare* Eintrag gewinnt — dieselbe
Bedingungsgrammatik wie überall sonst, kein neues Vokabular. Passt nichts, **setzt der Gegner aus**;
einer, der die Runde blockiert, wäre schlimmer als einer, der nichts tut.
Eine `ai`-Liste **ohne bedingungslosen letzten Eintrag** gibt eine Ladewarnung über die Maschinerie aus
04. Das ist fast immer ein Versehen, und zur Laufzeit erkennt man es nur daran, dass ein Gegner
gelegentlich stillsteht — die teuerste Sorte Fehler.
Boss-Phasen sind damit *nicht* gelöst, aber vorbereitet: Die Liste trägt sie, was fehlt, ist der
Phasenbegriff.
### Balance als Daten — ausdrückliche Ausnahme zu Ticket 03
Fünf Stellknöpfe entstehen hier: `AUSWEICH`, `FLUCHT`, Ausdauer-Regeneration, Resistenz-Deckel und
Treffer-Klemmbereich. Sie liegen als `[balance]`-Tabelle in TOML und verschmelzen **feldweise** über
die Ladereihenfolge.
Das widerspricht der Ganz-oder-gar-nicht-Regel aus 03, und zwar bewusst: Ein Mod, der nur `AUSWEICH`
dreht, müsste sonst alle fünf Werte abschreiben, und ein sechster Knopf in der nächsten Engine-Version
bräche ihn stillschweigend.
Die Ausnahme bleibt eng, weil ihre Begründung eng ist: **`[balance]` hat ein festes, der Engine
bekanntes Schema.** Genau das fehlte bei Inhalt mit offenen Feldern — Modifikator- und Aktionslisten
bräuchten einen Merge-Algorithmus, und das war 03s Argument gegen feldweises Patchen. Hier greift es
nicht.
Standardwerte liegen in der Engine, nicht in `core`. Dann ist ein Mod, der einen Knopf dreht, eine
Zeile lang.
### Vier Nachträge an geschlossene Tickets
| Ticket | Nachtrag |
|---|---|
| **01** | `damage`-Aktion: `menge = amount + scale * damage-Attribut(Verursacher)`, beide Felder optional (Standard 0). Deckt Basisangriff (`scale = 1.0`), Feuerball (`amount = 20`) und Wuchtschlag (`scale = 1.5`) mit *einer* Formel. Dazu `flee` als **achte Aktion** |
| **07** | `initiative: i32` und `cooldowns: HashMap<AbilityId, u32>` am `Creature`; RNG-Seed in `Game`; **`enemies` ist der laufende Kampf**, nicht die Etage |
| **08** | `combat_started` und `combat_ended { outcome: Victory \| Fled }` — die Eventliste wächst auf **elf**. Der Tod des Helden braucht keine dritte Variante, `creature_died` mit `source = Player` deckt ihn ab |
| **11** | **`GameOver`** als elfter Screen, Vollbild. Bewusst nicht die lazy Variante mit einer Meldungszeile im Hauptmenü: Ein Abstiegs-Roguelike lebt davon, dass der Lauf am Ende erzählt wird — Etage, Zug, Gold, Todesursache. *Ticket 10 hat daraus `GameOver { outcome: Died \| Won }` gemacht, weil der Abstieg von der letzten Etage den Lauf gewinnt* |
### Auflagen an andere Tickets
- **10 Raum-Prefab-Format**: füllt `Game.enemies`; Kampfbeginn ist ein Prefab-Ereignis.
- **15 Progression**: was bei Sieg passiert (EXP), und die Zahlen hinter allen Formeln inklusive der
fünf `[balance]`-Knöpfe.
- **16 Loot-Tabellen**: Beute hängt an der `creatures`-Registry, die hier entstanden ist.