224 lines
12 KiB
Markdown
224 lines
12 KiB
Markdown
# 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 |
|
||
|
||
### 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.
|